From 03364e9a94f15463526b7c177d84e0f8af906a57 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 7 Jul 2026 21:34:52 -0700 Subject: [PATCH 01/34] Create the Web-UI --- bin/qi | 1 + public/sandbox-ui.php | 14 + src/QuickInstall/Sandbox/Application.php | 53 +- .../Sandbox/BoardRefreshService.php | 53 ++ src/QuickInstall/Sandbox/Web/Application.php | 530 ++++++++++++++++++ src/QuickInstall/Sandbox/bootstrap.php | 26 + tests/Integration/ApplicationTest.php | 19 + tests/Integration/WebApplicationTest.php | 109 ++++ tests/bootstrap.php | 17 +- 9 files changed, 799 insertions(+), 23 deletions(-) create mode 100644 public/sandbox-ui.php create mode 100644 src/QuickInstall/Sandbox/BoardRefreshService.php create mode 100644 src/QuickInstall/Sandbox/Web/Application.php create mode 100644 src/QuickInstall/Sandbox/bootstrap.php create mode 100644 tests/Integration/WebApplicationTest.php diff --git a/bin/qi b/bin/qi index 34584b5a..42556dc5 100755 --- a/bin/qi +++ b/bin/qi @@ -24,6 +24,7 @@ require __DIR__ . '/../src/QuickInstall/Sandbox/BoardRunner.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/SeederWriter.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/ExtensionManager.php'; require __DIR__ . '/../src/QuickInstall/Sandbox/StyleManager.php'; +require __DIR__ . '/../src/QuickInstall/Sandbox/BoardRefreshService.php'; use QuickInstall\Sandbox\Application; diff --git a/public/sandbox-ui.php b/public/sandbox-ui.php new file mode 100644 index 00000000..798592a2 --- /dev/null +++ b/public/sandbox-ui.php @@ -0,0 +1,14 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +require_once __DIR__ . '/../src/QuickInstall/Sandbox/bootstrap.php'; +require_once __DIR__ . '/../src/QuickInstall/Sandbox/Web/Application.php'; + +(new QuickInstall\Sandbox\Web\Application(dirname(__DIR__)))->run(); diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 6929293f..2e5337fa 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -22,7 +22,7 @@ class Application public function __construct(string $root, $stderr = null, $stdin = null) { $this->project = new Project($root); - $this->stderr = $stderr ?: STDERR; + $this->stderr = $stderr ?: (defined('STDERR') ? STDERR : fopen('php://stderr', 'w')); $this->stdin = $stdin ?: (defined('STDIN') ? STDIN : null); } @@ -100,6 +100,9 @@ public function run(array $argv): int case 'style:list': return $this->styleList($argv); + case 'ui:start': + return $this->uiStart($argv); + default: $this->writeError("Unknown command: $command\n\n"); $this->help(); @@ -538,13 +541,7 @@ private function extUnmount(array $args): int private function refreshBoardIfRunning(string $board): void { - $runner = new BoardRunner($this->project, $this->sandboxOutput()); - (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); - if ($runner->status($board) === 'running') - { - $runner->recreateWeb($board); - $runner->purgeCache($board); - } + (new BoardRefreshService($this->project))->refreshIfRunning($board); } private function runtimeConfig(array $board): array @@ -567,6 +564,30 @@ private function runtimeConfig(array $board): array ]; } + private function uiStart(array $args): int + { + $cli = CommandLine::parse($args); + $host = $cli->option('host', '127.0.0.1'); + $port = (int) $cli->option('port', '8079'); + if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) + { + throw new InvalidArgumentException('ui:start only supports local loopback hosts: 127.0.0.1, localhost, or ::1.'); + } + if ($port < 1 || $port > 65535) + { + throw new InvalidArgumentException('--port must be between 1 and 65535.'); + } + + $router = dirname(__DIR__, 3) . '/public/sandbox-ui.php'; + echo "QuickInstall sandbox UI: http://{$host}:{$port}/\n"; + echo "Press Ctrl-C to stop the local UI server.\n"; + $command = [PHP_BINARY, '-S', $host . ':' . $port, $router]; + $process = new ProcessRunner(new StreamOutput()); + $process->run($command); + + return 0; + } + private function extList(array $args): int { $board = $this->boardName($args, 'Usage: qi ext:list '); @@ -984,6 +1005,22 @@ private function helpCommands(): array ], ], ], + 'UI commands' => [ + 'ui:start' => [ + 'title' => 'ui:start', + 'usage' => 'ui:start [--host 127.0.0.1] [--port 8079]', + 'summary' => 'Start the local sandbox admin UI.', + 'description' => 'Starts PHP built-in server on a loopback address and serves a minimal admin UI backed by the sandbox services.', + 'options' => [ + '--host HOST' => 'Loopback host. One of: 127.0.0.1, localhost, ::1. Default: 127.0.0.1.', + '--port PORT' => 'Local UI port. Default: 8079.', + ], + 'examples' => [ + 'ui:start', + 'ui:start --port 8088', + ], + ], + ], 'Source commands' => [ 'source:fetch' => [ 'title' => 'source:fetch', diff --git a/src/QuickInstall/Sandbox/BoardRefreshService.php b/src/QuickInstall/Sandbox/BoardRefreshService.php new file mode 100644 index 00000000..81c79fcc --- /dev/null +++ b/src/QuickInstall/Sandbox/BoardRefreshService.php @@ -0,0 +1,53 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +class BoardRefreshService +{ + private Project $project; + private BoardRunner $runner; + + public function __construct(Project $project, ?Output $output = null, ?BoardRunner $runner = null) + { + $this->project = $project; + $this->runner = $runner ?: new BoardRunner($project, $output); + } + + public function refreshIfRunning(string $board): void + { + (new DockerComposeWriter($this->project))->write($board, $this->runtimeConfig($this->project->board($board))); + if ($this->runner->status($board) === 'running') + { + $this->runner->recreateWeb($board); + $this->runner->purgeCache($board); + } + } + + private function runtimeConfig(array $board): array + { + if (empty($board['port']) && !empty($board['url'])) + { + $port = parse_url($board['url'], PHP_URL_PORT); + $board['port'] = $port ?: 80; + } + + return $board + [ + 'admin_name' => 'admin', + 'admin_pass' => 'password', + 'admin_email' => 'admin@example.test', + 'board_email' => 'board@example.test', + 'populate' => 'none', + 'debug' => false, + 'extensions' => [], + 'styles' => [], + ]; + } +} diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php new file mode 100644 index 00000000..fd6d3295 --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -0,0 +1,530 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox\Web; + +use InvalidArgumentException; +use QuickInstall\Sandbox\BoardRefreshService; +use QuickInstall\Sandbox\BoardService; +use QuickInstall\Sandbox\BufferedOutput; +use QuickInstall\Sandbox\ExtensionManager; +use QuickInstall\Sandbox\Project; +use QuickInstall\Sandbox\SourceService; +use QuickInstall\Sandbox\StyleManager; +use RuntimeException; + +class Application +{ + private Project $project; + private BufferedOutput $output; + private string $notice = ''; + private string $error = ''; + + public function __construct(string $root) + { + $this->project = new Project($root); + $this->output = new BufferedOutput(); + } + + public function run(): void + { + $this->assertLocalRequest(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') + { + $this->handlePost(); + if ($this->isAjax()) + { + $this->renderJson(); + return; + } + } + + $this->notice = $this->notice ?: (string) ($_GET['notice'] ?? ''); + $this->error = $this->error ?: (string) ($_GET['error'] ?? ''); + $this->render(); + } + + private function assertLocalRequest(): void + { + $remote = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + if (!in_array($remote, ['127.0.0.1', '::1'], true)) + { + http_response_code(403); + echo 'QuickInstall sandbox UI is local-only.'; + exit; + } + } + + private function handlePost(): void + { + try + { + $action = (string) ($_POST['action'] ?? ''); + switch ($action) + { + case 'init': + $created = $this->project->init(); + $this->notice = $created ? 'Workspace initialized.' : 'Workspace already initialized.'; + break; + + case 'source_fetch': + $version = $this->required('version'); + (new SourceService($this->project, $this->output))->fetch($version, $this->checked('git'), $this->optional('url'), $this->checked('allow_external')); + $this->notice = "Fetched source: $version"; + break; + + case 'board_create': + $name = $this->required('name'); + $version = $this->optional('phpbb') ?: 'latest'; + $db = $this->optional('db') ?: 'mariadb'; + $db = $db === 'sqlite3' ? 'sqlite' : $db; + $port = (int) ($this->optional('port') ?: '8080'); + $populate = $this->optional('populate') ?: 'none'; + $this->validateBoardCreateOptions($db, $port, $populate); + (new BoardService($this->project, $this->output))->create($name, $version, $db, $port, $populate, $this->checked('debug'), $this->checked('replace')); + $this->notice = "Created board: $name"; + break; + + case 'board_start': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->start($name); + $this->notice = "Started board: $name"; + break; + + case 'board_stop': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->stop($name); + $this->notice = "Stopped board: $name"; + break; + + case 'board_destroy': + $name = $this->required('name'); + (new BoardService($this->project, $this->output))->destroy($name); + $this->notice = "Destroyed board: $name"; + break; + + case 'board_seed': + $name = $this->required('name'); + $preset = $this->optional('preset') ?: 'extension-dev'; + $seed = (int) ($this->optional('seed') ?: '1'); + $this->validatePreset($preset); + if ($seed < 1) + { + throw new InvalidArgumentException('Seed must be a positive integer.'); + } + (new BoardService($this->project, $this->output))->seed($name, $preset, $seed, $this->optional('seed_action') ?: 'seed'); + $this->notice = "Seed action completed for board: $name"; + break; + + case 'ext_mount': + $this->mountCustomisation('extension', new ExtensionManager($this->project)); + break; + + case 'ext_unmount': + $board = $this->required('board'); + $name = $this->required('name'); + $extensions = new ExtensionManager($this->project); + $extensions->unmount($board, $name); + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + $extensions->cleanupStaleTarget($board, $name); + $this->notice = "Unmounted extension: $name"; + break; + + case 'style_mount': + $this->mountCustomisation('style', new StyleManager($this->project)); + break; + + case 'style_unmount': + $board = $this->required('board'); + $name = $this->required('name'); + $styles = new StyleManager($this->project); + $styles->unmount($board, $name); + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + $styles->cleanupStaleTarget($board, $name); + $this->notice = "Unmounted style: $name"; + break; + + default: + throw new InvalidArgumentException('Unknown action.'); + } + } + catch (InvalidArgumentException | RuntimeException $e) + { + $this->error = $e->getMessage(); + } + } + + private function mountCustomisation(string $type, object $manager): void + { + $board = $this->required('board'); + $source = $this->required('source'); + if ($this->checked('copy') && $this->checked('recursive')) + { + throw new InvalidArgumentException('Recursive mount cannot be combined with copy mode.'); + } + + if ($this->checked('recursive')) + { + $mounted = 0; + foreach ($manager->discover($source, $this->checked('allow_external')) as $path) + { + $manager->mount($board, $path, false, $this->checked('allow_external')); + $mounted++; + } + if ($mounted > 0) + { + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + } + $this->notice = "Mounted {$mounted} {$type}(s) on $board"; + return; + } + + $mounted = $manager->mount($board, $source, $this->checked('copy'), $this->checked('allow_external')); + (new BoardRefreshService($this->project, $this->output))->refreshIfRunning($board); + $this->notice = "Mounted {$type}: {$mounted['name']}"; + } + + private function required(string $name): string + { + $value = trim((string) ($_POST[$name] ?? '')); + if ($value === '') + { + throw new InvalidArgumentException("$name is required."); + } + + return $value; + } + + private function optional(string $name): ?string + { + $value = trim((string) ($_POST[$name] ?? '')); + return $value === '' ? null : $value; + } + + private function checked(string $name): bool + { + return !empty($_POST[$name]); + } + + private function validateBoardCreateOptions(string $db, int $port, string $populate): void + { + if (!in_array($db, ['mariadb', 'mysql', 'postgres', 'sqlite'], true)) + { + throw new InvalidArgumentException('DB must be one of: mariadb, mysql, postgres, sqlite.'); + } + if ($port < 1 || $port > 65535) + { + throw new InvalidArgumentException('Port must be between 1 and 65535.'); + } + if ($populate !== 'none') + { + $this->validatePreset($populate); + } + if ($db === 'sqlite' && $populate !== 'none') + { + throw new InvalidArgumentException('SQLite boards support populate none only.'); + } + } + + private function validatePreset(string $preset): void + { + if (!in_array($preset, ['tiny', 'extension-dev', 'load-test', 'random'], true)) + { + throw new InvalidArgumentException('Preset must be one of: tiny, extension-dev, load-test, random.'); + } + } + + private function render(): void + { + if (!headers_sent()) + { + header('Content-Type: text/html; charset=utf-8'); + } + echo ''; + echo 'QuickInstall Sandbox'; + echo ''; + echo '
'; + echo ''; + echo '
'; + echo '

Local admin UI

Sandbox workspace

Manage disposable phpBB boards backed by the same Docker services as the CLI.

'; + echo '
'; + $this->renderDashboard(); + echo '
'; + echo '
Processing sandbox action...
'; + echo ''; + echo ''; + } + + private function renderJson(): void + { + if (!headers_sent()) + { + header('Content-Type: application/json; charset=utf-8'); + } + + ob_start(); + $this->renderDashboard(); + $html = (string) ob_get_clean(); + + echo json_encode([ + 'ok' => $this->error === '', + 'notice' => $this->notice, + 'error' => $this->error, + 'output' => $this->output->all(), + 'html' => $html, + ], JSON_UNESCAPED_SLASHES); + } + + private function renderDashboard(): void + { + $boards = (new BoardService($this->project, $this->output))->list(); + $sources = (new SourceService($this->project))->list(); + $versions = (new SourceService($this->project))->supportedVersions(); + $extensions = new ExtensionManager($this->project); + $styles = new StyleManager($this->project); + + $running = count(array_filter($boards, static function ($board) { + return ($board['status'] ?? '') === 'running'; + })); + $mountedExtensions = 0; + $mountedStyles = 0; + foreach ($boards as $board) + { + $mountedExtensions += count($board['extensions'] ?? []); + $mountedStyles += count($board['styles'] ?? []); + } + + echo '
'; + $this->metric('Boards', (string) count($boards), $running . ' running', 'Runtime definitions'); + $this->metric('Sources', (string) count($sources), count(array_filter($sources, static function ($source) { return !empty($source['downloaded']); })) . ' downloaded', 'phpBB cache'); + $this->metric('Extensions', (string) $mountedExtensions, 'mounted', 'Board mounts'); + $this->metric('Styles', (string) $mountedStyles, 'mounted', 'Board mounts'); + echo '
'; + if ($this->notice !== '' || $this->error !== '') + { + echo '
'; + if ($this->notice !== '') + { + echo '

' . $this->e($this->notice) . '

'; + } + if ($this->error !== '') + { + echo '

' . $this->e($this->error) . '

'; + } + echo '
'; + } + $this->renderBoards($boards, $extensions, $styles); + $this->renderCreateBoard($versions); + $this->renderCustomisations($boards); + $this->renderSources($sources); + $this->renderActivity(); + } + + private function renderBoards(array $boards, ExtensionManager $extensions, StyleManager $styles): void + { + echo '

Runtime

Boards

Start, stop, seed, and inspect local Docker-backed phpBB installs.

'; + if (!$boards) + { + echo '
No boards createdCreate a board below to generate its compose runtime and workspace files.
'; + } + echo '
'; + foreach ($boards as $board) + { + $name = (string) $board['name']; + $status = (string) ($board['status'] ?? 'unknown'); + echo '

' . $this->e($name) . '

' . $this->e($board['url']) . '

' . $this->e($status) . '
'; + echo '
phpBB
' . $this->e($board['phpbb']) . '
PHP
' . $this->e($board['php']) . '
Database
' . $this->e($board['db']) . '
Populate
' . $this->e($board['populate'] ?? 'none') . '
'; + echo '
'; + echo 'Open'; + $this->actionButton('board_start', $name, 'Start', 'primary'); + $this->actionButton('board_stop', $name, 'Stop', 'secondary'); + $this->actionButton('board_destroy', $name, 'Destroy', 'danger', 'Destroy board ' . $name . '? This removes its files, database, containers, and local image.'); + echo '
'; + echo '
Seed content
'; + $this->renderSeedForm($name); + echo '
'; + $this->renderMounted('Extensions', 'ext_unmount', $name, $extensions->list($name)); + $this->renderMounted('Styles', 'style_unmount', $name, $styles->list($name)); + echo '
'; + echo '
'; + } + echo '
'; + echo '
'; + } + + private function renderCreateBoard(array $versions): void + { + echo '

Provision

Create board

Choose the phpBB source, runtime, port, and optional seed preset.

'; + echo ''; + $this->input('name', 'Name', 'demo'); + $this->input('phpbb', 'phpBB', 'latest', $this->versionOptions($versions)); + $this->select('db', 'DB', ['mariadb', 'mysql', 'postgres', 'sqlite']); + $this->input('port', 'Port', '8080'); + $this->select('populate', 'Populate', ['none', 'tiny', 'extension-dev', 'load-test', 'random']); + $this->checkbox('debug', 'Debug'); + $this->checkbox('replace', 'Replace existing'); + echo '
'; + } + + private function renderSources(array $sources): void + { + echo '

Source cache

Sources

Fetch and inspect phpBB sources used by boards.

'; + echo ''; + $this->input('version', 'Version or branch', 'latest'); + $this->input('url', 'Git URL'); + $this->checkbox('git', 'Git source'); + $this->checkbox('allow_external', 'Allow external URL'); + echo '
'; + if (!$sources) + { + echo '
No sources registeredFetching a source will populate the local .qi source cache.
'; + } + else + { + echo '
'; + foreach ($sources as $source) + { + echo ''; + } + echo '
SourceVersionStatusDownloadedUsed byPath
' . $this->e($source['source_key'] ?? '') . '' . $this->e($source['version'] ?? '') . '' . $this->e($source['status'] ?? '-') . '' . (!empty($source['downloaded']) ? 'yes' : 'no') . '' . $this->e(implode(', ', $source['used_by'] ?? [])) . '' . $this->e($source['path'] ?? '') . '
'; + } + echo '
'; + } + + private function renderCustomisations(array $boards): void + { + echo '

Mounts

Customisations

Bind or copy local extension and style work into a board.

'; + $this->renderMountForm('ext_mount', 'Mount extension', $boards); + $this->renderMountForm('style_mount', 'Mount style', $boards); + echo '
'; + } + + private function renderMountForm(string $action, string $title, array $boards): void + { + echo '

' . $this->e($title) . '

'; + echo ''; + $this->boardSelect($boards); + $this->input('source', 'Path under customisations/'); + $this->checkbox('copy', 'Copy'); + $this->checkbox('recursive', 'Recursive'); + $this->checkbox('allow_external', 'Allow external path'); + echo '
'; + } + + private function renderSeedForm(string $name): void + { + echo '
'; + $this->select('preset', 'Preset', ['tiny', 'extension-dev', 'load-test', 'random']); + $this->input('seed', 'Seed', '1'); + $this->select('seed_action', 'Action', ['seed', 'replace', 'reset']); + echo '
'; + } + + private function renderMounted(string $title, string $action, string $board, array $items): void + { + echo '

' . $this->e($title) . '

'; + if (!$items) + { + echo '

None mounted

'; + return; + } + foreach ($items as $item) + { + echo '
' . $this->e($item['name']) . '' . $this->e($item['mode']) . ''; + echo ''; + echo '
'; + } + echo ''; + } + + private function actionButton(string $action, string $name, string $label, string $class = '', string $confirm = ''): void + { + $confirmAttr = $confirm === '' ? '' : ' data-confirm="' . $this->e($confirm) . '"'; + echo '
'; + } + + private function renderActivity(): void + { + $output = $this->output->all(); + echo '

Command output

Activity log

Docker, Composer, and seed output from the latest action.

'; + echo '
quickinstall sandbox
' . ($output === '' ? 'No command output yet.' : $this->e($output)) . '
'; + } + + private function metric(string $label, string $value, string $detail, string $description): void + { + echo '
' . $this->e($label) . '' . $this->e($value) . '' . $this->e($detail) . '' . $this->e($description) . '
'; + } + + private function boardSelect(array $boards): void + { + echo ''; + } + + private function input(string $name, string $label, string $value = '', array $datalist = []): void + { + $list = $datalist ? ' list="' . $this->e($name) . '-list"' : ''; + echo ''; + if ($datalist) + { + echo ''; + foreach ($datalist as $option) + { + echo ''; + } + } + + private function select(string $name, string $label, array $options): void + { + echo ''; + } + + private function checkbox(string $name, string $label): void + { + echo ''; + } + + private function versionOptions(array $versions): array + { + return ['latest', '3.3', '3.3.x', '3.2', '3.2.x', '4.0.x', 'master']; + } + + private function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function css(): string + { + return ':root{--canvas:#f6f8fa;--panel:#fff;--text:#24292f;--muted:#57606a;--subtle:#6e7781;--border:#d0d7de;--border-muted:#d8dee4;--row:#f6f8fa;--row-hover:#f3f4f6;--accent:#0b76b7;--accent-hover:#075f94;--success:#1a7f37;--success-bg:#dafbe1;--warning:#9a6700;--warning-bg:#fff8c5;--danger:#cf222e;--danger-bg:#ffebe9;--terminal:#0d1117;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--focus:0 0 0 3px rgba(11,118,183,.24)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--canvas);color:var(--text)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin:0}h1{font-size:26px;line-height:1.25;font-weight:650}h2{font-size:20px;line-height:1.3;font-weight:650}h3{font-size:16px;line-height:1.4;font-weight:650}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:5px 12px;border:1px solid rgba(27,31,36,.15);border-radius:var(--radius);background:#f6f8fa;color:var(--text);font:600 14px/20px inherit;box-shadow:var(--shadow);cursor:pointer;text-decoration:none;white-space:nowrap}button:hover,.button:hover{background:#f3f4f6;text-decoration:none}button:focus-visible,.button:focus-visible,input:focus,select:focus,a:focus-visible{outline:none;box-shadow:var(--focus);border-color:var(--accent)}button:disabled{opacity:.65;cursor:wait}.primary{background:var(--accent);border-color:rgba(27,31,36,.15);color:#fff}.primary:hover{background:var(--accent-hover)}.secondary{background:#f6f8fa;color:var(--text)}.danger{background:#fff;color:var(--danger);border-color:rgba(207,34,46,.35)}.danger:hover{background:var(--danger-bg)}.app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:24px 16px;background:#fff;border-right:1px solid var(--border-muted)}.brand{display:flex;align-items:center;gap:12px;padding:0 8px 20px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:#24292f;color:#fff;font-size:12px;font-weight:700;letter-spacing:.04em}.brand strong{display:block;font-size:15px}.brand small{display:block;color:var(--muted);font-size:12px}.sidebar nav{display:grid;gap:2px}.sidebar a{display:block;padding:7px 10px;border-radius:var(--radius);color:var(--text);font-weight:500}.sidebar a:hover{background:var(--row);text-decoration:none}.main{min-width:0}.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding:24px 32px;background:#fff;border-bottom:1px solid var(--border-muted)}.lede{margin-top:4px;color:var(--muted);max-width:680px}.content{max-width:1220px;margin:0 auto;padding-bottom:40px}.eyebrow{margin-bottom:3px;color:var(--subtle);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.status-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:24px 32px 0}.metric,.card{background:var(--panel);border:1px solid var(--border-muted);border-radius:var(--radius);box-shadow:var(--shadow)}.metric{padding:14px 16px}.metric span{display:block;color:var(--muted);font-size:12px;font-weight:600}.metric strong{display:block;margin-top:4px;font-size:24px;line-height:1.2}.metric small{display:block;margin-top:2px;color:var(--text);font-weight:600}.metric em{display:block;margin-top:8px;color:var(--subtle);font-size:12px;font-style:normal}.toast-stack{display:grid;gap:8px;padding:16px 32px 0}.notice,.error{padding:10px 12px;border:1px solid;border-radius:var(--radius);font-weight:500}.notice{background:var(--success-bg);border-color:#aceebb;color:#116329}.error{background:var(--danger-bg);border-color:#ffcecb;color:#82071e}.section{padding:24px 32px 0}.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;padding-bottom:8px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.section-head p:not(.eyebrow){margin-top:4px;color:var(--muted);max-width:760px}.board-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px}.board{padding:0}.card-head{display:flex;justify-content:space-between;gap:16px;padding:16px;border-bottom:1px solid var(--border-muted)}.card-head .url{margin-top:3px;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}.badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:999px;background:#eaeef2;color:var(--muted);font-size:12px;font-weight:600;text-transform:capitalize}.status-running{background:var(--success-bg);color:var(--success)}.status-error,.status-missing{background:var(--danger-bg);color:var(--danger)}.status-partial{background:var(--warning-bg);color:var(--warning)}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0;padding:12px 16px;gap:0;border-bottom:1px solid var(--border-muted)}.facts div{min-width:0;padding-right:12px}.facts dt{color:var(--subtle);font-size:12px}.facts dd{margin:2px 0 0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.compact{padding:12px 16px;border-bottom:1px solid var(--border-muted)}.subsection-title{padding:12px 16px 0;color:var(--muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.seed-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:10px 16px 16px;border-bottom:1px solid var(--border-muted)}.mounted-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0}.mounted{padding:14px 16px}.mounted + .mounted{border-left:1px solid var(--border-muted)}.mounted h4{margin-bottom:8px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--border-muted)}.row:first-of-type{border-top:0}.row span{min-width:0}.row strong,.row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row small{color:var(--subtle);font-size:12px}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px}.settings-form h3{grid-column:1/-1}.compact-form{grid-template-columns:repeat(4,minmax(0,1fr))}.stack-form{grid-template-columns:1fr 1fr}.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;padding-top:4px}.field{display:grid;gap:6px;color:var(--text);font-weight:600}.field span{font-size:13px}.field input,.field select{width:100%;height:32px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);padding:5px 8px;font:14px/20px inherit}.field input[name=source],td:last-child{font-family:ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;font-size:12px}.toggle{display:grid;grid-template-columns:36px minmax(0,1fr);gap:10px;align-items:center;align-self:end;min-height:32px;color:var(--text);font-weight:600}.toggle input{position:absolute;opacity:0;pointer-events:none}.toggle span{position:relative;width:36px;height:20px;border:1px solid var(--border);border-radius:999px;background:#eaeef2;transition:background .12s ease}.toggle span:before{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(27,31,36,.25);transition:transform .12s ease}.toggle input:checked + span{background:var(--accent);border-color:var(--accent)}.toggle input:checked + span:before{transform:translateX(16px)}.toggle input:focus-visible + span{box-shadow:var(--focus)}.table-wrap{overflow:auto;border:1px solid var(--border-muted);border-radius:var(--radius);background:#fff}table{width:100%;min-width:820px;border-collapse:collapse}th,td{padding:9px 12px;border-bottom:1px solid var(--border-muted);text-align:left;vertical-align:top}th{background:var(--row);color:var(--muted);font-size:12px;font-weight:600}tr:hover td{background:#fbfcfd}tr:last-child td{border-bottom:0}.empty{display:grid;gap:3px;padding:18px;border:1px dashed var(--border);border-radius:var(--radius);background:#fff;color:var(--muted)}.empty strong{color:var(--text)}.muted{color:var(--subtle)}.terminal{overflow:hidden;border:1px solid #30363d;border-radius:var(--radius);background:var(--terminal);box-shadow:var(--shadow)}.terminal-bar{display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;border-bottom:1px solid #30363d;background:#161b22;color:#8b949e;font-size:12px}.terminal-bar span{width:10px;height:10px;border-radius:50%;background:#484f58}.terminal-bar span:first-child{background:#f85149}.terminal-bar span:nth-child(2){background:#d29922}.terminal-bar span:nth-child(3){background:#3fb950}.terminal-bar strong{margin-left:6px;font-weight:600}.progress{display:none;height:2px;background:#21262d;overflow:hidden}.progress span{display:block;width:38%;height:100%;background:var(--accent);animation:progress 1.1s ease-in-out infinite}.is-processing .progress{display:block}.activity-log{height:300px;overflow:auto;margin:0;padding:14px;color:#e6edf3;background:var(--terminal);font:12px/1.55 ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;white-space:pre-wrap}.busy{position:fixed;right:20px;bottom:20px;display:none;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);box-shadow:0 8px 24px rgba(140,149,159,.25);z-index:20}.busy.is-active{display:flex}.spinner{width:16px;height:16px;border:2px solid #d0d7de;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes progress{0%{transform:translateX(-110%)}100%{transform:translateX(285%)}}@media(max-width:1080px){.app{grid-template-columns:1fr}.sidebar{position:relative;height:auto;border-right:0;border-bottom:1px solid var(--border-muted)}.sidebar nav{display:flex;overflow:auto}.sidebar a{white-space:nowrap}.status-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}}@media(max-width:760px){.topbar{flex-direction:column;padding:20px}.content{max-width:none}.section,.status-strip,.toast-stack{padding-left:20px;padding-right:20px}.board-grid{grid-template-columns:1fr}.settings-form,.compact-form,.stack-form,.seed-form{grid-template-columns:1fr}.facts{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:10px}.mounted-grid{grid-template-columns:1fr}.mounted + .mounted{border-left:0;border-top:1px solid var(--border-muted)}}'; + } + + private function js(): string + { + return "const dashboard=document.getElementById('dashboard');const busy=document.getElementById('busy');function setProcessing(active){busy.classList.toggle('is-active',active);document.documentElement.classList.toggle('is-processing',active)}function bindAjax(){document.querySelectorAll('form[data-ajax]').forEach(form=>{if(form.dataset.bound)return;form.dataset.bound='1';form.addEventListener('submit',async event=>{event.preventDefault();if(form.dataset.confirm&&!window.confirm(form.dataset.confirm))return;const submitter=event.submitter;const original=submitter?submitter.textContent:'';if(submitter){submitter.disabled=true;submitter.textContent='Working...'}setProcessing(true);try{const response=await fetch(location.href,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:new FormData(form)});const data=await response.json();dashboard.innerHTML=data.html;bindAjax();scrollLog();}catch(error){alert('Request failed: '+error.message);}finally{setProcessing(false);if(submitter){submitter.disabled=false;submitter.textContent=original;}}});});document.querySelectorAll('[data-clear-log]').forEach(button=>{if(button.dataset.bound)return;button.dataset.bound='1';button.addEventListener('click',()=>{const log=document.getElementById('activity-log');if(log)log.textContent='No command output yet.';});});}function scrollLog(){const log=document.getElementById('activity-log');if(log)log.scrollTop=log.scrollHeight;}bindAjax();scrollLog();"; + } + + private function isAjax(): bool + { + return strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'; + } +} diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php new file mode 100644 index 00000000..39e0b390 --- /dev/null +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -0,0 +1,26 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +require_once __DIR__ . '/Output.php'; +require_once __DIR__ . '/StreamOutput.php'; +require_once __DIR__ . '/BufferedOutput.php'; +require_once __DIR__ . '/ProcessRunner.php'; +require_once __DIR__ . '/CommandLine.php'; +require_once __DIR__ . '/Project.php'; +require_once __DIR__ . '/VersionMatrix.php'; +require_once __DIR__ . '/SourceProvider.php'; +require_once __DIR__ . '/SourceService.php'; +require_once __DIR__ . '/BoardService.php'; +require_once __DIR__ . '/DockerComposeWriter.php'; +require_once __DIR__ . '/BoardRunner.php'; +require_once __DIR__ . '/SeederWriter.php'; +require_once __DIR__ . '/ExtensionManager.php'; +require_once __DIR__ . '/StyleManager.php'; +require_once __DIR__ . '/BoardRefreshService.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 6fd94b15..6d9c1a7c 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -123,6 +123,25 @@ public function testBoardSeedRejectsConflictingResetAndReplace(): void self::assertStringContainsString('Use --reset or --replace, not both.', $result['stderr']); } + public function testUiStartHelpIsExposed(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'ui:start', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('Usage:', $result['output']); + self::assertStringContainsString('qi ui:start', $result['output']); + self::assertStringContainsString('built-in server', $result['output']); + } + + public function testUiStartRejectsNonLocalHostBeforeStartingServer(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'ui:start', '--host', '0.0.0.0']); + + self::assertSame(1, $result['exit_code']); + self::assertSame('', $result['output']); + self::assertStringContainsString('local loopback hosts', $result['stderr']); + } + public function testUnknownCommandReturnsFailureAndHelp(): void { $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'nope']); diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php new file mode 100644 index 00000000..ebbaf7f1 --- /dev/null +++ b/tests/Integration/WebApplicationTest.php @@ -0,0 +1,109 @@ +serverBackup = $_SERVER; + $this->postBackup = $_POST; + $this->getBackup = $_GET; + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + $_POST = $this->postBackup; + $_GET = $this->getBackup; + $this->cleanupTempPaths(); + } + + public function testRenderShowsCoreSandboxWorkflows(): void + { + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + $project->appendBoard([ + 'name' => 'demo', + 'phpbb' => '3.3.14', + 'phpbb_source' => '3.3.14', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'db' => 'mariadb', + 'port' => 8081, + 'url' => 'http://localhost:8081/', + 'path' => $project->boardPath('demo'), + 'populate' => 'none', + 'debug' => false, + 'extensions' => [], + 'styles' => [], + ]); + + $html = $this->runWebApplication($root); + + self::assertStringContainsString('QuickInstall Sandbox', $html); + self::assertStringContainsString('Create board', $html); + self::assertStringContainsString('Sources', $html); + self::assertStringContainsString('Mount extension', $html); + self::assertStringContainsString('Mount style', $html); + self::assertStringContainsString('board_start', $html); + self::assertStringContainsString('board_seed', $html); + self::assertStringContainsString('data-ajax', $html); + self::assertStringContainsString('activity-log', $html); + } + + public function testInitPostCreatesWorkspace(): void + { + $root = $this->createTempProjectRoot(); + + $html = $this->runWebApplication($root, ['action' => 'init']); + + self::assertDirectoryExists($root . '/.qi'); + self::assertFileExists($root . '/.qi/boards.json'); + self::assertStringContainsString('Workspace initialized.', $html); + } + + public function testAjaxPostReturnsDashboardJson(): void + { + $root = $this->createTempProjectRoot(); + + $json = $this->runWebApplication($root, ['action' => 'init'], true); + $data = json_decode($json, true); + + self::assertIsArray($data); + self::assertTrue($data['ok']); + self::assertSame('Workspace initialized.', $data['notice']); + self::assertStringContainsString('status-strip', $data['html']); + self::assertStringContainsString('activity-log', $data['html']); + } + + private function runWebApplication(string $root, array $post = [], bool $ajax = false): string + { + $_SERVER['REQUEST_METHOD'] = $post ? 'POST' : 'GET'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + if ($ajax) + { + $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; + } + $_POST = $post; + $_GET = []; + + ob_start(); + (new Application($root))->run(); + return (string) ob_get_clean(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c4f58ad8..564622e4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,21 +1,8 @@ Date: Tue, 7 Jul 2026 22:03:37 -0700 Subject: [PATCH 02/34] Add remove source action buttons --- src/QuickInstall/Sandbox/Web/Application.php | 34 +++++++++++++-- tests/Integration/WebApplicationTest.php | 44 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index fd6d3295..d4f12ce8 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -81,6 +81,16 @@ private function handlePost(): void $this->notice = "Fetched source: $version"; break; + case 'source_remove': + $source = $this->required('source'); + $removed = (new SourceService($this->project))->remove($source, $this->checked('force')); + $this->notice = "Removed source: {$removed['source']['source_key']}"; + if (!empty($removed['used_by'])) + { + $this->notice .= ' (was referenced by ' . implode(', ', $removed['used_by']) . ')'; + } + break; + case 'board_create': $name = $this->required('name'); $version = $this->optional('phpbb') ?: 'latest'; @@ -389,16 +399,34 @@ private function renderSources(array $sources): void } else { - echo '
'; + echo '
SourceVersionStatusDownloadedUsed byPath
'; foreach ($sources as $source) { - echo ''; + $sourceKey = (string) ($source['source_key'] ?? ''); + $usedBy = $source['used_by'] ?? []; + echo ''; } echo '
SourceVersionStatusDownloadedUsed byPathActions
' . $this->e($source['source_key'] ?? '') . '' . $this->e($source['version'] ?? '') . '' . $this->e($source['status'] ?? '-') . '' . (!empty($source['downloaded']) ? 'yes' : 'no') . '' . $this->e(implode(', ', $source['used_by'] ?? [])) . '' . $this->e($source['path'] ?? '') . '
' . $this->e($sourceKey) . '' . $this->e($source['version'] ?? '') . '' . $this->e($source['status'] ?? '-') . '' . (!empty($source['downloaded']) ? 'yes' : 'no') . '' . $this->e($usedBy ? implode(', ', $usedBy) : '-') . '' . $this->e($source['path'] ?? '') . ''; + $this->sourceRemoveForm($sourceKey, $usedBy); + echo '
'; } echo ''; } + private function sourceRemoveForm(string $sourceKey, array $usedBy): void + { + $confirm = $usedBy + ? 'Remove source ' . $sourceKey . '? It is referenced by: ' . implode(', ', $usedBy) . '. Boards may need another source before they can be recreated.' + : 'Remove source ' . $sourceKey . '? This deletes the cached source files.'; + echo '
'; + echo ''; + if ($usedBy) + { + echo ''; + } + echo '
'; + } + private function renderCustomisations(array $boards): void { echo '

Mounts

Customisations

Bind or copy local extension and style work into a board.

'; @@ -515,7 +543,7 @@ private function e(string $value): string private function css(): string { - return ':root{--canvas:#f6f8fa;--panel:#fff;--text:#24292f;--muted:#57606a;--subtle:#6e7781;--border:#d0d7de;--border-muted:#d8dee4;--row:#f6f8fa;--row-hover:#f3f4f6;--accent:#0b76b7;--accent-hover:#075f94;--success:#1a7f37;--success-bg:#dafbe1;--warning:#9a6700;--warning-bg:#fff8c5;--danger:#cf222e;--danger-bg:#ffebe9;--terminal:#0d1117;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--focus:0 0 0 3px rgba(11,118,183,.24)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--canvas);color:var(--text)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin:0}h1{font-size:26px;line-height:1.25;font-weight:650}h2{font-size:20px;line-height:1.3;font-weight:650}h3{font-size:16px;line-height:1.4;font-weight:650}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:5px 12px;border:1px solid rgba(27,31,36,.15);border-radius:var(--radius);background:#f6f8fa;color:var(--text);font:600 14px/20px inherit;box-shadow:var(--shadow);cursor:pointer;text-decoration:none;white-space:nowrap}button:hover,.button:hover{background:#f3f4f6;text-decoration:none}button:focus-visible,.button:focus-visible,input:focus,select:focus,a:focus-visible{outline:none;box-shadow:var(--focus);border-color:var(--accent)}button:disabled{opacity:.65;cursor:wait}.primary{background:var(--accent);border-color:rgba(27,31,36,.15);color:#fff}.primary:hover{background:var(--accent-hover)}.secondary{background:#f6f8fa;color:var(--text)}.danger{background:#fff;color:var(--danger);border-color:rgba(207,34,46,.35)}.danger:hover{background:var(--danger-bg)}.app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:24px 16px;background:#fff;border-right:1px solid var(--border-muted)}.brand{display:flex;align-items:center;gap:12px;padding:0 8px 20px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:#24292f;color:#fff;font-size:12px;font-weight:700;letter-spacing:.04em}.brand strong{display:block;font-size:15px}.brand small{display:block;color:var(--muted);font-size:12px}.sidebar nav{display:grid;gap:2px}.sidebar a{display:block;padding:7px 10px;border-radius:var(--radius);color:var(--text);font-weight:500}.sidebar a:hover{background:var(--row);text-decoration:none}.main{min-width:0}.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding:24px 32px;background:#fff;border-bottom:1px solid var(--border-muted)}.lede{margin-top:4px;color:var(--muted);max-width:680px}.content{max-width:1220px;margin:0 auto;padding-bottom:40px}.eyebrow{margin-bottom:3px;color:var(--subtle);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.status-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:24px 32px 0}.metric,.card{background:var(--panel);border:1px solid var(--border-muted);border-radius:var(--radius);box-shadow:var(--shadow)}.metric{padding:14px 16px}.metric span{display:block;color:var(--muted);font-size:12px;font-weight:600}.metric strong{display:block;margin-top:4px;font-size:24px;line-height:1.2}.metric small{display:block;margin-top:2px;color:var(--text);font-weight:600}.metric em{display:block;margin-top:8px;color:var(--subtle);font-size:12px;font-style:normal}.toast-stack{display:grid;gap:8px;padding:16px 32px 0}.notice,.error{padding:10px 12px;border:1px solid;border-radius:var(--radius);font-weight:500}.notice{background:var(--success-bg);border-color:#aceebb;color:#116329}.error{background:var(--danger-bg);border-color:#ffcecb;color:#82071e}.section{padding:24px 32px 0}.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;padding-bottom:8px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.section-head p:not(.eyebrow){margin-top:4px;color:var(--muted);max-width:760px}.board-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px}.board{padding:0}.card-head{display:flex;justify-content:space-between;gap:16px;padding:16px;border-bottom:1px solid var(--border-muted)}.card-head .url{margin-top:3px;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}.badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:999px;background:#eaeef2;color:var(--muted);font-size:12px;font-weight:600;text-transform:capitalize}.status-running{background:var(--success-bg);color:var(--success)}.status-error,.status-missing{background:var(--danger-bg);color:var(--danger)}.status-partial{background:var(--warning-bg);color:var(--warning)}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0;padding:12px 16px;gap:0;border-bottom:1px solid var(--border-muted)}.facts div{min-width:0;padding-right:12px}.facts dt{color:var(--subtle);font-size:12px}.facts dd{margin:2px 0 0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.compact{padding:12px 16px;border-bottom:1px solid var(--border-muted)}.subsection-title{padding:12px 16px 0;color:var(--muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.seed-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:10px 16px 16px;border-bottom:1px solid var(--border-muted)}.mounted-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0}.mounted{padding:14px 16px}.mounted + .mounted{border-left:1px solid var(--border-muted)}.mounted h4{margin-bottom:8px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--border-muted)}.row:first-of-type{border-top:0}.row span{min-width:0}.row strong,.row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row small{color:var(--subtle);font-size:12px}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px}.settings-form h3{grid-column:1/-1}.compact-form{grid-template-columns:repeat(4,minmax(0,1fr))}.stack-form{grid-template-columns:1fr 1fr}.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;padding-top:4px}.field{display:grid;gap:6px;color:var(--text);font-weight:600}.field span{font-size:13px}.field input,.field select{width:100%;height:32px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);padding:5px 8px;font:14px/20px inherit}.field input[name=source],td:last-child{font-family:ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;font-size:12px}.toggle{display:grid;grid-template-columns:36px minmax(0,1fr);gap:10px;align-items:center;align-self:end;min-height:32px;color:var(--text);font-weight:600}.toggle input{position:absolute;opacity:0;pointer-events:none}.toggle span{position:relative;width:36px;height:20px;border:1px solid var(--border);border-radius:999px;background:#eaeef2;transition:background .12s ease}.toggle span:before{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(27,31,36,.25);transition:transform .12s ease}.toggle input:checked + span{background:var(--accent);border-color:var(--accent)}.toggle input:checked + span:before{transform:translateX(16px)}.toggle input:focus-visible + span{box-shadow:var(--focus)}.table-wrap{overflow:auto;border:1px solid var(--border-muted);border-radius:var(--radius);background:#fff}table{width:100%;min-width:820px;border-collapse:collapse}th,td{padding:9px 12px;border-bottom:1px solid var(--border-muted);text-align:left;vertical-align:top}th{background:var(--row);color:var(--muted);font-size:12px;font-weight:600}tr:hover td{background:#fbfcfd}tr:last-child td{border-bottom:0}.empty{display:grid;gap:3px;padding:18px;border:1px dashed var(--border);border-radius:var(--radius);background:#fff;color:var(--muted)}.empty strong{color:var(--text)}.muted{color:var(--subtle)}.terminal{overflow:hidden;border:1px solid #30363d;border-radius:var(--radius);background:var(--terminal);box-shadow:var(--shadow)}.terminal-bar{display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;border-bottom:1px solid #30363d;background:#161b22;color:#8b949e;font-size:12px}.terminal-bar span{width:10px;height:10px;border-radius:50%;background:#484f58}.terminal-bar span:first-child{background:#f85149}.terminal-bar span:nth-child(2){background:#d29922}.terminal-bar span:nth-child(3){background:#3fb950}.terminal-bar strong{margin-left:6px;font-weight:600}.progress{display:none;height:2px;background:#21262d;overflow:hidden}.progress span{display:block;width:38%;height:100%;background:var(--accent);animation:progress 1.1s ease-in-out infinite}.is-processing .progress{display:block}.activity-log{height:300px;overflow:auto;margin:0;padding:14px;color:#e6edf3;background:var(--terminal);font:12px/1.55 ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;white-space:pre-wrap}.busy{position:fixed;right:20px;bottom:20px;display:none;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);box-shadow:0 8px 24px rgba(140,149,159,.25);z-index:20}.busy.is-active{display:flex}.spinner{width:16px;height:16px;border:2px solid #d0d7de;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes progress{0%{transform:translateX(-110%)}100%{transform:translateX(285%)}}@media(max-width:1080px){.app{grid-template-columns:1fr}.sidebar{position:relative;height:auto;border-right:0;border-bottom:1px solid var(--border-muted)}.sidebar nav{display:flex;overflow:auto}.sidebar a{white-space:nowrap}.status-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}}@media(max-width:760px){.topbar{flex-direction:column;padding:20px}.content{max-width:none}.section,.status-strip,.toast-stack{padding-left:20px;padding-right:20px}.board-grid{grid-template-columns:1fr}.settings-form,.compact-form,.stack-form,.seed-form{grid-template-columns:1fr}.facts{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:10px}.mounted-grid{grid-template-columns:1fr}.mounted + .mounted{border-left:0;border-top:1px solid var(--border-muted)}}'; + return ':root{--canvas:#f6f8fa;--panel:#fff;--text:#24292f;--muted:#57606a;--subtle:#6e7781;--border:#d0d7de;--border-muted:#d8dee4;--row:#f6f8fa;--row-hover:#f3f4f6;--accent:#0b76b7;--accent-hover:#075f94;--success:#1a7f37;--success-bg:#dafbe1;--warning:#9a6700;--warning-bg:#fff8c5;--danger:#cf222e;--danger-bg:#ffebe9;--terminal:#0d1117;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--focus:0 0 0 3px rgba(11,118,183,.24)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--canvas);color:var(--text)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin:0}h1{font-size:26px;line-height:1.25;font-weight:650}h2{font-size:20px;line-height:1.3;font-weight:650}h3{font-size:16px;line-height:1.4;font-weight:650}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:5px 12px;border:1px solid rgba(27,31,36,.15);border-radius:var(--radius);background:#f6f8fa;color:var(--text);font:600 14px/20px inherit;box-shadow:var(--shadow);cursor:pointer;text-decoration:none;white-space:nowrap}button:hover,.button:hover{background:#f3f4f6;text-decoration:none}button:focus-visible,.button:focus-visible,input:focus,select:focus,a:focus-visible{outline:none;box-shadow:var(--focus);border-color:var(--accent)}button:disabled{opacity:.65;cursor:wait}.primary{background:var(--accent);border-color:rgba(27,31,36,.15);color:#fff}.primary:hover{background:var(--accent-hover)}.secondary{background:#f6f8fa;color:var(--text)}.danger{background:#fff;color:var(--danger);border-color:rgba(207,34,46,.35)}.danger:hover{background:var(--danger-bg)}.app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:24px 16px;background:#fff;border-right:1px solid var(--border-muted)}.brand{display:flex;align-items:center;gap:12px;padding:0 8px 20px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:#24292f;color:#fff;font-size:12px;font-weight:700;letter-spacing:.04em}.brand strong{display:block;font-size:15px}.brand small{display:block;color:var(--muted);font-size:12px}.sidebar nav{display:grid;gap:2px}.sidebar a{display:block;padding:7px 10px;border-radius:var(--radius);color:var(--text);font-weight:500}.sidebar a:hover{background:var(--row);text-decoration:none}.main{min-width:0}.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding:24px 32px;background:#fff;border-bottom:1px solid var(--border-muted)}.lede{margin-top:4px;color:var(--muted);max-width:680px}.content{max-width:1220px;margin:0 auto;padding-bottom:40px}.eyebrow{margin-bottom:3px;color:var(--subtle);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.status-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:24px 32px 0}.metric,.card{background:var(--panel);border:1px solid var(--border-muted);border-radius:var(--radius);box-shadow:var(--shadow)}.metric{padding:14px 16px}.metric span{display:block;color:var(--muted);font-size:12px;font-weight:600}.metric strong{display:block;margin-top:4px;font-size:24px;line-height:1.2}.metric small{display:block;margin-top:2px;color:var(--text);font-weight:600}.metric em{display:block;margin-top:8px;color:var(--subtle);font-size:12px;font-style:normal}.toast-stack{display:grid;gap:8px;padding:16px 32px 0}.notice,.error{padding:10px 12px;border:1px solid;border-radius:var(--radius);font-weight:500}.notice{background:var(--success-bg);border-color:#aceebb;color:#116329}.error{background:var(--danger-bg);border-color:#ffcecb;color:#82071e}.section{padding:24px 32px 0}.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;padding-bottom:8px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.section-head p:not(.eyebrow){margin-top:4px;color:var(--muted);max-width:760px}.board-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px}.board{padding:0}.card-head{display:flex;justify-content:space-between;gap:16px;padding:16px;border-bottom:1px solid var(--border-muted)}.card-head .url{margin-top:3px;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}.badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:999px;background:#eaeef2;color:var(--muted);font-size:12px;font-weight:600;text-transform:capitalize}.status-running{background:var(--success-bg);color:var(--success)}.status-error,.status-missing{background:var(--danger-bg);color:var(--danger)}.status-partial{background:var(--warning-bg);color:var(--warning)}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0;padding:12px 16px;gap:0;border-bottom:1px solid var(--border-muted)}.facts div{min-width:0;padding-right:12px}.facts dt{color:var(--subtle);font-size:12px}.facts dd{margin:2px 0 0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.compact{padding:12px 16px;border-bottom:1px solid var(--border-muted)}.subsection-title{padding:12px 16px 0;color:var(--muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.seed-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:10px 16px 16px;border-bottom:1px solid var(--border-muted)}.mounted-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0}.mounted{padding:14px 16px}.mounted + .mounted{border-left:1px solid var(--border-muted)}.mounted h4{margin-bottom:8px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--border-muted)}.row:first-of-type{border-top:0}.row span{min-width:0}.row strong,.row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row small{color:var(--subtle);font-size:12px}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px}.settings-form h3{grid-column:1/-1}.compact-form{grid-template-columns:repeat(4,minmax(0,1fr))}.stack-form{grid-template-columns:1fr 1fr}.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;padding-top:4px}.field{display:grid;gap:6px;color:var(--text);font-weight:600}.field span{font-size:13px}.field input,.field select{width:100%;height:32px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);padding:5px 8px;font:14px/20px inherit}.field input[name=source],td:last-child{font-family:ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;font-size:12px}.toggle{display:grid;grid-template-columns:36px minmax(0,1fr);gap:10px;align-items:center;align-self:end;min-height:32px;color:var(--text);font-weight:600}.toggle input{position:absolute;opacity:0;pointer-events:none}.toggle span{position:relative;width:36px;height:20px;border:1px solid var(--border);border-radius:999px;background:#eaeef2;transition:background .12s ease}.toggle span:before{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(27,31,36,.25);transition:transform .12s ease}.toggle input:checked + span{background:var(--accent);border-color:var(--accent)}.toggle input:checked + span:before{transform:translateX(16px)}.toggle input:focus-visible + span{box-shadow:var(--focus)}.table-wrap{overflow:auto;border:1px solid var(--border-muted);border-radius:var(--radius);background:#fff}table{width:100%;min-width:920px;border-collapse:collapse}th,td{padding:9px 12px;border-bottom:1px solid var(--border-muted);text-align:left;vertical-align:top}th{background:var(--row);color:var(--muted);font-size:12px;font-weight:600}tr:hover td{background:#fbfcfd}tr:last-child td{border-bottom:0}.actions-column{width:1%;white-space:nowrap}.source-remove{display:flex;justify-content:flex-end}.empty{display:grid;gap:3px;padding:18px;border:1px dashed var(--border);border-radius:var(--radius);background:#fff;color:var(--muted)}.empty strong{color:var(--text)}.muted{color:var(--subtle)}.terminal{overflow:hidden;border:1px solid #30363d;border-radius:var(--radius);background:var(--terminal);box-shadow:var(--shadow)}.terminal-bar{display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;border-bottom:1px solid #30363d;background:#161b22;color:#8b949e;font-size:12px}.terminal-bar span{width:10px;height:10px;border-radius:50%;background:#484f58}.terminal-bar span:first-child{background:#f85149}.terminal-bar span:nth-child(2){background:#d29922}.terminal-bar span:nth-child(3){background:#3fb950}.terminal-bar strong{margin-left:6px;font-weight:600}.progress{display:none;height:2px;background:#21262d;overflow:hidden}.progress span{display:block;width:38%;height:100%;background:var(--accent);animation:progress 1.1s ease-in-out infinite}.is-processing .progress{display:block}.activity-log{height:300px;overflow:auto;margin:0;padding:14px;color:#e6edf3;background:var(--terminal);font:12px/1.55 ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;white-space:pre-wrap}.busy{position:fixed;right:20px;bottom:20px;display:none;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);box-shadow:0 8px 24px rgba(140,149,159,.25);z-index:20}.busy.is-active{display:flex}.spinner{width:16px;height:16px;border:2px solid #d0d7de;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes progress{0%{transform:translateX(-110%)}100%{transform:translateX(285%)}}@media(max-width:1080px){.app{grid-template-columns:1fr}.sidebar{position:relative;height:auto;border-right:0;border-bottom:1px solid var(--border-muted)}.sidebar nav{display:flex;overflow:auto}.sidebar a{white-space:nowrap}.status-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}}@media(max-width:760px){.topbar{flex-direction:column;padding:20px}.content{max-width:none}.section,.status-strip,.toast-stack{padding-left:20px;padding-right:20px}.board-grid{grid-template-columns:1fr}.settings-form,.compact-form,.stack-form,.seed-form{grid-template-columns:1fr}.facts{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:10px}.mounted-grid{grid-template-columns:1fr}.mounted + .mounted{border-left:0;border-top:1px solid var(--border-muted)}}'; } private function js(): string diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index ebbaf7f1..9a8c21ad 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -37,6 +37,7 @@ public function testRenderShowsCoreSandboxWorkflows(): void $root = $this->createTempProjectRoot(); $project = new Project($root); $project->init(); + $this->addDownloadedSource($project, '3.3.14'); $project->appendBoard([ 'name' => 'demo', 'phpbb' => '3.3.14', @@ -62,6 +63,7 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('Mount style', $html); self::assertStringContainsString('board_start', $html); self::assertStringContainsString('board_seed', $html); + self::assertStringContainsString('source_remove', $html); self::assertStringContainsString('data-ajax', $html); self::assertStringContainsString('activity-log', $html); } @@ -91,6 +93,26 @@ public function testAjaxPostReturnsDashboardJson(): void self::assertStringContainsString('activity-log', $data['html']); } + public function testAjaxSourceRemoveDeletesSource(): void + { + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + $this->addDownloadedSource($project, '3.3.14'); + + $json = $this->runWebApplication($root, [ + 'action' => 'source_remove', + 'source' => '3.3.14', + ], true); + $data = json_decode($json, true); + + self::assertIsArray($data); + self::assertTrue($data['ok']); + self::assertSame('Removed source: 3.3.14', $data['notice']); + self::assertSame([], $project->readJson('sources.json', [])); + self::assertStringNotContainsString('value="3.3.14"', $data['html']); + } + private function runWebApplication(string $root, array $post = [], bool $ajax = false): string { $_SERVER['REQUEST_METHOD'] = $post ? 'POST' : 'GET'; @@ -106,4 +128,26 @@ private function runWebApplication(string $root, array $post = [], bool $ajax = (new Application($root))->run(); return (string) ob_get_clean(); } + + private function addDownloadedSource(Project $project, string $key): void + { + $sourcePath = $project->sourcePath($key); + mkdir($sourcePath, 0775, true); + file_put_contents($sourcePath . '/common.php', 'writeJson('sources.json', [ + $key => [ + 'version' => $key, + 'source_key' => $key, + 'constraint' => $key, + 'branch' => '3.3', + 'phpbb_branch' => '3.3', + 'php' => '8.1', + 'status' => 'supported', + 'type' => 'composer', + 'package' => 'phpbb/phpbb', + 'url' => null, + 'path' => $sourcePath, + ], + ]); + } } From 6a4f5b1f799c9dbb313e12db8db9f35853381d9e Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Tue, 7 Jul 2026 22:23:50 -0700 Subject: [PATCH 03/34] Use html templates and static assets --- public/assets/sandbox-ui.css | 479 ++++++++++++++++++ public/assets/sandbox-ui.js | 77 +++ public/sandbox-ui.php | 19 + src/QuickInstall/Sandbox/Web/Application.php | 285 ++--------- .../Sandbox/Web/templates/dashboard.php | 222 ++++++++ .../Sandbox/Web/templates/layout.php | 50 ++ tests/Integration/WebApplicationTest.php | 3 + 7 files changed, 892 insertions(+), 243 deletions(-) create mode 100644 public/assets/sandbox-ui.css create mode 100644 public/assets/sandbox-ui.js create mode 100644 src/QuickInstall/Sandbox/Web/templates/dashboard.php create mode 100644 src/QuickInstall/Sandbox/Web/templates/layout.php diff --git a/public/assets/sandbox-ui.css b/public/assets/sandbox-ui.css new file mode 100644 index 00000000..e7925141 --- /dev/null +++ b/public/assets/sandbox-ui.css @@ -0,0 +1,479 @@ +:root { + --canvas: #f6f8fa; + --panel: #fff; + --text: #24292f; + --muted: #57606a; + --subtle: #6e7781; + --border: #d0d7de; + --border-muted: #d8dee4; + --row: #f6f8fa; + --accent: #0b76b7; + --accent-hover: #075f94; + --success: #1a7f37; + --success-bg: #dafbe1; + --warning: #9a6700; + --warning-bg: #fff8c5; + --danger: #cf222e; + --danger-bg: #ffebe9; + --terminal: #0d1117; + --radius: 6px; + --shadow: 0 1px 0 rgba(27, 31, 36, .04); + --focus: 0 0 0 3px rgba(11, 118, 183, .24); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--canvas); + color: var(--text); +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3, h4, p { margin: 0; } +h1 { font-size: 26px; line-height: 1.25; font-weight: 650; } +h2 { font-size: 20px; line-height: 1.3; font-weight: 650; } +h3 { font-size: 16px; line-height: 1.4; font-weight: 650; } + +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 5px 12px; + border: 1px solid rgba(27, 31, 36, .15); + border-radius: var(--radius); + background: #f6f8fa; + color: var(--text); + font: 600 14px/20px inherit; + box-shadow: var(--shadow); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +button:hover, +.button:hover { background: #f3f4f6; text-decoration: none; } +button:focus-visible, +.button:focus-visible, +input:focus, +select:focus, +a:focus-visible { + outline: none; + box-shadow: var(--focus); + border-color: var(--accent); +} +button:disabled { opacity: .65; cursor: wait; } +.primary { background: var(--accent); border-color: rgba(27, 31, 36, .15); color: #fff; } +.primary:hover { background: var(--accent-hover); } +.secondary { background: #f6f8fa; color: var(--text); } +.danger { background: #fff; color: var(--danger); border-color: rgba(207, 34, 46, .35); } +.danger:hover { background: var(--danger-bg); } + +.app { + display: grid; + grid-template-columns: 264px minmax(0, 1fr); + min-height: 100vh; +} +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 16px; + background: #fff; + border-right: 1px solid var(--border-muted); +} +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 20px; + border-bottom: 1px solid var(--border-muted); + margin-bottom: 16px; +} +.brand-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius); + background: #24292f; + color: #fff; + font-size: 12px; + font-weight: 700; + letter-spacing: .04em; +} +.brand strong { display: block; font-size: 15px; } +.brand small { display: block; color: var(--muted); font-size: 12px; } +.sidebar nav { display: grid; gap: 2px; } +.sidebar a { + display: block; + padding: 7px 10px; + border-radius: var(--radius); + color: var(--text); + font-weight: 500; +} +.sidebar a:hover { background: var(--row); text-decoration: none; } +.main { min-width: 0; } +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding: 24px 32px; + background: #fff; + border-bottom: 1px solid var(--border-muted); +} +.lede { margin-top: 4px; color: var(--muted); max-width: 680px; } +.content { max-width: 1220px; margin: 0 auto; padding-bottom: 40px; } +.eyebrow { + margin-bottom: 3px; + color: var(--subtle); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; +} + +.status-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 24px 32px 0; +} +.metric, +.card { + background: var(--panel); + border: 1px solid var(--border-muted); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.metric { padding: 14px 16px; } +.metric span { display: block; color: var(--muted); font-size: 12px; font-weight: 600; } +.metric strong { display: block; margin-top: 4px; font-size: 24px; line-height: 1.2; } +.metric small { display: block; margin-top: 2px; color: var(--text); font-weight: 600; } +.metric em { display: block; margin-top: 8px; color: var(--subtle); font-size: 12px; font-style: normal; } + +.toast-stack { display: grid; gap: 8px; padding: 16px 32px 0; } +.notice, +.error { + padding: 10px 12px; + border: 1px solid; + border-radius: var(--radius); + font-weight: 500; +} +.notice { background: var(--success-bg); border-color: #aceebb; color: #116329; } +.error { background: var(--danger-bg); border-color: #ffcecb; color: #82071e; } + +.section { padding: 24px 32px 0; } +.section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-muted); + margin-bottom: 16px; +} +.section-head p:not(.eyebrow) { margin-top: 4px; color: var(--muted); max-width: 760px; } +.board-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; } +.board { padding: 0; } +.card-head { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--border-muted); +} +.card-head .url { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 260px; +} +.badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + background: #eaeef2; + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} +.status-running { background: var(--success-bg); color: var(--success); } +.status-error, +.status-missing { background: var(--danger-bg); color: var(--danger); } +.status-partial { background: var(--warning-bg); color: var(--warning); } +.facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + padding: 12px 16px; + gap: 0; + border-bottom: 1px solid var(--border-muted); +} +.facts div { min-width: 0; padding-right: 12px; } +.facts dt { color: var(--subtle); font-size: 12px; } +.facts dd { + margin: 2px 0 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.compact { padding: 12px 16px; border-bottom: 1px solid var(--border-muted); } +.subsection-title { + padding: 12px 16px 0; + color: var(--muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; +} +.seed-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + align-items: end; + padding: 10px 16px 16px; + border-bottom: 1px solid var(--border-muted); +} +.mounted-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; } +.mounted { padding: 14px 16px; } +.mounted + .mounted { border-left: 1px solid var(--border-muted); } +.mounted h4 { + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .04em; +} +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--border-muted); +} +.row:first-of-type { border-top: 0; } +.row span { min-width: 0; } +.row strong, +.row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row small { color: var(--subtle); font-size: 12px; } + +.settings-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + padding: 16px; +} +.settings-form h3 { grid-column: 1 / -1; } +.compact-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.stack-form { grid-template-columns: 1fr 1fr; } +.split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.form-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 4px; } +.field { display: grid; gap: 6px; color: var(--text); font-weight: 600; } +.field span { font-size: 13px; } +.field input, +.field select { + width: 100%; + height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + color: var(--text); + padding: 5px 8px; + font: 14px/20px inherit; +} +.field input[name=source], +td:last-child { + font-family: ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} +.toggle { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + align-items: center; + align-self: end; + min-height: 32px; + color: var(--text); + font-weight: 600; +} +.toggle input { position: absolute; opacity: 0; pointer-events: none; } +.toggle span { + position: relative; + width: 36px; + height: 20px; + border: 1px solid var(--border); + border-radius: 999px; + background: #eaeef2; + transition: background .12s ease; +} +.toggle span:before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(27, 31, 36, .25); + transition: transform .12s ease; +} +.toggle input:checked + span { background: var(--accent); border-color: var(--accent); } +.toggle input:checked + span:before { transform: translateX(16px); } +.toggle input:focus-visible + span { box-shadow: var(--focus); } + +.table-wrap { + overflow: auto; + border: 1px solid var(--border-muted); + border-radius: var(--radius); + background: #fff; +} +table { width: 100%; min-width: 920px; border-collapse: collapse; } +th, +td { + padding: 9px 12px; + border-bottom: 1px solid var(--border-muted); + text-align: left; + vertical-align: top; +} +th { background: var(--row); color: var(--muted); font-size: 12px; font-weight: 600; } +tr:hover td { background: #fbfcfd; } +tr:last-child td { border-bottom: 0; } +.actions-column { width: 1%; white-space: nowrap; } +.source-remove { display: flex; justify-content: flex-end; } +.empty { + display: grid; + gap: 3px; + padding: 18px; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: #fff; + color: var(--muted); +} +.empty strong { color: var(--text); } +.muted { color: var(--subtle); } + +.terminal { + overflow: hidden; + border: 1px solid #30363d; + border-radius: var(--radius); + background: var(--terminal); + box-shadow: var(--shadow); +} +.terminal-bar { + display: flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 12px; + border-bottom: 1px solid #30363d; + background: #161b22; + color: #8b949e; + font-size: 12px; +} +.terminal-bar span { width: 10px; height: 10px; border-radius: 50%; background: #484f58; } +.terminal-bar span:first-child { background: #f85149; } +.terminal-bar span:nth-child(2) { background: #d29922; } +.terminal-bar span:nth-child(3) { background: #3fb950; } +.terminal-bar strong { margin-left: 6px; font-weight: 600; } +.progress { display: none; height: 2px; background: #21262d; overflow: hidden; } +.progress span { + display: block; + width: 38%; + height: 100%; + background: var(--accent); + animation: progress 1.1s ease-in-out infinite; +} +.is-processing .progress { display: block; } +.activity-log { + height: 300px; + overflow: auto; + margin: 0; + padding: 14px; + color: #e6edf3; + background: var(--terminal); + font: 12px/1.55 ui-monospace, SFMono-Regular, SFMono, Consolas, "Liberation Mono", Menlo, monospace; + white-space: pre-wrap; +} +.busy { + position: fixed; + right: 20px; + bottom: 20px; + display: none; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + color: var(--text); + box-shadow: 0 8px 24px rgba(140, 149, 159, .25); + z-index: 20; +} +.busy.is-active { display: flex; } +.spinner { + width: 16px; + height: 16px; + border: 2px solid #d0d7de; + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes progress { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(285%); } +} + +@media (max-width: 1080px) { + .app { grid-template-columns: 1fr; } + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border-muted); + } + .sidebar nav { display: flex; overflow: auto; } + .sidebar a { white-space: nowrap; } + .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .topbar { flex-direction: column; padding: 20px; } + .content { max-width: none; } + .section, + .status-strip, + .toast-stack { padding-left: 20px; padding-right: 20px; } + .board-grid { grid-template-columns: 1fr; } + .settings-form, + .compact-form, + .stack-form, + .seed-form { grid-template-columns: 1fr; } + .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 10px; } + .mounted-grid { grid-template-columns: 1fr; } + .mounted + .mounted { + border-left: 0; + border-top: 1px solid var(--border-muted); + } +} diff --git a/public/assets/sandbox-ui.js b/public/assets/sandbox-ui.js new file mode 100644 index 00000000..a0cbc243 --- /dev/null +++ b/public/assets/sandbox-ui.js @@ -0,0 +1,77 @@ +const dashboard = document.getElementById('dashboard'); +const busy = document.getElementById('busy'); + +function setProcessing(active) { + busy.classList.toggle('is-active', active); + document.documentElement.classList.toggle('is-processing', active); +} + +function bindAjax() { + document.querySelectorAll('form[data-ajax]').forEach((form) => { + if (form.dataset.bound) { + return; + } + + form.dataset.bound = '1'; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) { + return; + } + + const submitter = event.submitter; + const original = submitter ? submitter.textContent : ''; + if (submitter) { + submitter.disabled = true; + submitter.textContent = 'Working...'; + } + + setProcessing(true); + try { + const response = await fetch(location.href, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new FormData(form), + }); + const data = await response.json(); + dashboard.innerHTML = data.html; + bindAjax(); + scrollLog(); + } catch (error) { + alert('Request failed: ' + error.message); + } finally { + setProcessing(false); + if (submitter) { + submitter.disabled = false; + submitter.textContent = original; + } + } + }); + }); + + document.querySelectorAll('[data-clear-log]').forEach((button) => { + if (button.dataset.bound) { + return; + } + + button.dataset.bound = '1'; + button.addEventListener('click', () => { + const log = document.getElementById('activity-log'); + if (log) { + log.textContent = 'No command output yet.'; + } + }); + }); +} + +function scrollLog() { + const log = document.getElementById('activity-log'); + if (log) { + log.scrollTop = log.scrollHeight; + } +} + +bindAjax(); +scrollLog(); diff --git a/public/sandbox-ui.php b/public/sandbox-ui.php index 798592a2..c2eb28ad 100644 --- a/public/sandbox-ui.php +++ b/public/sandbox-ui.php @@ -8,6 +8,25 @@ * */ +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$file = realpath(__DIR__ . $path); +if (PHP_SAPI === 'cli-server' && $file !== false && str_starts_with($file, __DIR__ . DIRECTORY_SEPARATOR) && is_file($file)) +{ + $extension = pathinfo($file, PATHINFO_EXTENSION); + $contentTypes = [ + 'css' => 'text/css; charset=utf-8', + 'js' => 'application/javascript; charset=utf-8', + ]; + if (isset($contentTypes[$extension])) + { + header('Content-Type: ' . $contentTypes[$extension]); + readfile($file); + return true; + } + + return false; +} + require_once __DIR__ . '/../src/QuickInstall/Sandbox/bootstrap.php'; require_once __DIR__ . '/../src/QuickInstall/Sandbox/Web/Application.php'; diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index d4f12ce8..aec80dc8 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -258,19 +258,9 @@ private function render(): void { header('Content-Type: text/html; charset=utf-8'); } - echo ''; - echo 'QuickInstall Sandbox'; - echo ''; - echo '
'; - echo ''; - echo '
'; - echo '

Local admin UI

Sandbox workspace

Manage disposable phpBB boards backed by the same Docker services as the CLI.

'; - echo '
'; - $this->renderDashboard(); - echo '
'; - echo '
Processing sandbox action...
'; - echo ''; - echo ''; + echo $this->renderTemplate('layout.php', [ + 'dashboard' => $this->renderTemplate('dashboard.php', $this->viewData()), + ]); } private function renderJson(): void @@ -280,9 +270,7 @@ private function renderJson(): void header('Content-Type: application/json; charset=utf-8'); } - ob_start(); - $this->renderDashboard(); - $html = (string) ob_get_clean(); + $html = $this->renderTemplate('dashboard.php', $this->viewData()); echo json_encode([ 'ok' => $this->error === '', @@ -293,7 +281,7 @@ private function renderJson(): void ], JSON_UNESCAPED_SLASHES); } - private function renderDashboard(): void + private function viewData(): array { $boards = (new BoardService($this->project, $this->output))->list(); $sources = (new SourceService($this->project))->list(); @@ -312,243 +300,54 @@ private function renderDashboard(): void $mountedStyles += count($board['styles'] ?? []); } - echo '
'; - $this->metric('Boards', (string) count($boards), $running . ' running', 'Runtime definitions'); - $this->metric('Sources', (string) count($sources), count(array_filter($sources, static function ($source) { return !empty($source['downloaded']); })) . ' downloaded', 'phpBB cache'); - $this->metric('Extensions', (string) $mountedExtensions, 'mounted', 'Board mounts'); - $this->metric('Styles', (string) $mountedStyles, 'mounted', 'Board mounts'); - echo '
'; - if ($this->notice !== '' || $this->error !== '') - { - echo '
'; - if ($this->notice !== '') - { - echo '

' . $this->e($this->notice) . '

'; - } - if ($this->error !== '') - { - echo '

' . $this->e($this->error) . '

'; - } - echo '
'; - } - $this->renderBoards($boards, $extensions, $styles); - $this->renderCreateBoard($versions); - $this->renderCustomisations($boards); - $this->renderSources($sources); - $this->renderActivity(); - } - - private function renderBoards(array $boards, ExtensionManager $extensions, StyleManager $styles): void - { - echo '

Runtime

Boards

Start, stop, seed, and inspect local Docker-backed phpBB installs.

'; - if (!$boards) - { - echo '
No boards createdCreate a board below to generate its compose runtime and workspace files.
'; - } - echo '
'; + $viewBoards = []; foreach ($boards as $board) { $name = (string) $board['name']; - $status = (string) ($board['status'] ?? 'unknown'); - echo '

' . $this->e($name) . '

' . $this->e($board['url']) . '

' . $this->e($status) . '
'; - echo '
phpBB
' . $this->e($board['phpbb']) . '
PHP
' . $this->e($board['php']) . '
Database
' . $this->e($board['db']) . '
Populate
' . $this->e($board['populate'] ?? 'none') . '
'; - echo '
'; - echo 'Open'; - $this->actionButton('board_start', $name, 'Start', 'primary'); - $this->actionButton('board_stop', $name, 'Stop', 'secondary'); - $this->actionButton('board_destroy', $name, 'Destroy', 'danger', 'Destroy board ' . $name . '? This removes its files, database, containers, and local image.'); - echo '
'; - echo '
Seed content
'; - $this->renderSeedForm($name); - echo '
'; - $this->renderMounted('Extensions', 'ext_unmount', $name, $extensions->list($name)); - $this->renderMounted('Styles', 'style_unmount', $name, $styles->list($name)); - echo '
'; - echo '
'; + $board['mounted_extensions'] = $extensions->list($name); + $board['mounted_styles'] = $styles->list($name); + $viewBoards[] = $board; } - echo '
'; - echo '
'; - } - - private function renderCreateBoard(array $versions): void - { - echo '

Provision

Create board

Choose the phpBB source, runtime, port, and optional seed preset.

'; - echo ''; - $this->input('name', 'Name', 'demo'); - $this->input('phpbb', 'phpBB', 'latest', $this->versionOptions($versions)); - $this->select('db', 'DB', ['mariadb', 'mysql', 'postgres', 'sqlite']); - $this->input('port', 'Port', '8080'); - $this->select('populate', 'Populate', ['none', 'tiny', 'extension-dev', 'load-test', 'random']); - $this->checkbox('debug', 'Debug'); - $this->checkbox('replace', 'Replace existing'); - echo '
'; - } - private function renderSources(array $sources): void - { - echo '

Source cache

Sources

Fetch and inspect phpBB sources used by boards.

'; - echo ''; - $this->input('version', 'Version or branch', 'latest'); - $this->input('url', 'Git URL'); - $this->checkbox('git', 'Git source'); - $this->checkbox('allow_external', 'Allow external URL'); - echo '
'; - if (!$sources) - { - echo '
No sources registeredFetching a source will populate the local .qi source cache.
'; - } - else - { - echo '
'; - foreach ($sources as $source) - { - $sourceKey = (string) ($source['source_key'] ?? ''); - $usedBy = $source['used_by'] ?? []; - echo ''; - } - echo '
SourceVersionStatusDownloadedUsed byPathActions
' . $this->e($sourceKey) . '' . $this->e($source['version'] ?? '') . '' . $this->e($source['status'] ?? '-') . '' . (!empty($source['downloaded']) ? 'yes' : 'no') . '' . $this->e($usedBy ? implode(', ', $usedBy) : '-') . '' . $this->e($source['path'] ?? '') . ''; - $this->sourceRemoveForm($sourceKey, $usedBy); - echo '
'; - } - echo '
'; - } - - private function sourceRemoveForm(string $sourceKey, array $usedBy): void - { - $confirm = $usedBy - ? 'Remove source ' . $sourceKey . '? It is referenced by: ' . implode(', ', $usedBy) . '. Boards may need another source before they can be recreated.' - : 'Remove source ' . $sourceKey . '? This deletes the cached source files.'; - echo '
'; - echo ''; - if ($usedBy) - { - echo ''; - } - echo '
'; - } - - private function renderCustomisations(array $boards): void - { - echo '

Mounts

Customisations

Bind or copy local extension and style work into a board.

'; - $this->renderMountForm('ext_mount', 'Mount extension', $boards); - $this->renderMountForm('style_mount', 'Mount style', $boards); - echo '
'; - } - - private function renderMountForm(string $action, string $title, array $boards): void - { - echo '

' . $this->e($title) . '

'; - echo ''; - $this->boardSelect($boards); - $this->input('source', 'Path under customisations/'); - $this->checkbox('copy', 'Copy'); - $this->checkbox('recursive', 'Recursive'); - $this->checkbox('allow_external', 'Allow external path'); - echo '
'; - } - - private function renderSeedForm(string $name): void - { - echo '
'; - $this->select('preset', 'Preset', ['tiny', 'extension-dev', 'load-test', 'random']); - $this->input('seed', 'Seed', '1'); - $this->select('seed_action', 'Action', ['seed', 'replace', 'reset']); - echo '
'; - } - - private function renderMounted(string $title, string $action, string $board, array $items): void - { - echo '

' . $this->e($title) . '

'; - if (!$items) - { - echo '

None mounted

'; - return; - } - foreach ($items as $item) - { - echo '
' . $this->e($item['name']) . '' . $this->e($item['mode']) . ''; - echo ''; - echo '
'; - } - echo '
'; - } - - private function actionButton(string $action, string $name, string $label, string $class = '', string $confirm = ''): void - { - $confirmAttr = $confirm === '' ? '' : ' data-confirm="' . $this->e($confirm) . '"'; - echo '
'; - } - - private function renderActivity(): void - { - $output = $this->output->all(); - echo '

Command output

Activity log

Docker, Composer, and seed output from the latest action.

'; - echo '
quickinstall sandbox
' . ($output === '' ? 'No command output yet.' : $this->e($output)) . '
'; - } - - private function metric(string $label, string $value, string $detail, string $description): void - { - echo '
' . $this->e($label) . '' . $this->e($value) . '' . $this->e($detail) . '' . $this->e($description) . '
'; - } - - private function boardSelect(array $boards): void - { - echo ''; - } - - private function input(string $name, string $label, string $value = '', array $datalist = []): void - { - $list = $datalist ? ' list="' . $this->e($name) . '-list"' : ''; - echo ''; - if ($datalist) - { - echo ''; - foreach ($datalist as $option) - { - echo ''; - } - } - - private function select(string $name, string $label, array $options): void - { - echo ''; - } - private function checkbox(string $name, string $label): void - { - echo ''; - } - - private function versionOptions(array $versions): array - { - return ['latest', '3.3', '3.3.x', '3.2', '3.2.x', '4.0.x', 'master']; - } - - private function e(string $value): string - { - return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - } - - private function css(): string - { - return ':root{--canvas:#f6f8fa;--panel:#fff;--text:#24292f;--muted:#57606a;--subtle:#6e7781;--border:#d0d7de;--border-muted:#d8dee4;--row:#f6f8fa;--row-hover:#f3f4f6;--accent:#0b76b7;--accent-hover:#075f94;--success:#1a7f37;--success-bg:#dafbe1;--warning:#9a6700;--warning-bg:#fff8c5;--danger:#cf222e;--danger-bg:#ffebe9;--terminal:#0d1117;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--focus:0 0 0 3px rgba(11,118,183,.24)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--canvas);color:var(--text)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin:0}h1{font-size:26px;line-height:1.25;font-weight:650}h2{font-size:20px;line-height:1.3;font-weight:650}h3{font-size:16px;line-height:1.4;font-weight:650}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:5px 12px;border:1px solid rgba(27,31,36,.15);border-radius:var(--radius);background:#f6f8fa;color:var(--text);font:600 14px/20px inherit;box-shadow:var(--shadow);cursor:pointer;text-decoration:none;white-space:nowrap}button:hover,.button:hover{background:#f3f4f6;text-decoration:none}button:focus-visible,.button:focus-visible,input:focus,select:focus,a:focus-visible{outline:none;box-shadow:var(--focus);border-color:var(--accent)}button:disabled{opacity:.65;cursor:wait}.primary{background:var(--accent);border-color:rgba(27,31,36,.15);color:#fff}.primary:hover{background:var(--accent-hover)}.secondary{background:#f6f8fa;color:var(--text)}.danger{background:#fff;color:var(--danger);border-color:rgba(207,34,46,.35)}.danger:hover{background:var(--danger-bg)}.app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;padding:24px 16px;background:#fff;border-right:1px solid var(--border-muted)}.brand{display:flex;align-items:center;gap:12px;padding:0 8px 20px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:#24292f;color:#fff;font-size:12px;font-weight:700;letter-spacing:.04em}.brand strong{display:block;font-size:15px}.brand small{display:block;color:var(--muted);font-size:12px}.sidebar nav{display:grid;gap:2px}.sidebar a{display:block;padding:7px 10px;border-radius:var(--radius);color:var(--text);font-weight:500}.sidebar a:hover{background:var(--row);text-decoration:none}.main{min-width:0}.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding:24px 32px;background:#fff;border-bottom:1px solid var(--border-muted)}.lede{margin-top:4px;color:var(--muted);max-width:680px}.content{max-width:1220px;margin:0 auto;padding-bottom:40px}.eyebrow{margin-bottom:3px;color:var(--subtle);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}.status-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:24px 32px 0}.metric,.card{background:var(--panel);border:1px solid var(--border-muted);border-radius:var(--radius);box-shadow:var(--shadow)}.metric{padding:14px 16px}.metric span{display:block;color:var(--muted);font-size:12px;font-weight:600}.metric strong{display:block;margin-top:4px;font-size:24px;line-height:1.2}.metric small{display:block;margin-top:2px;color:var(--text);font-weight:600}.metric em{display:block;margin-top:8px;color:var(--subtle);font-size:12px;font-style:normal}.toast-stack{display:grid;gap:8px;padding:16px 32px 0}.notice,.error{padding:10px 12px;border:1px solid;border-radius:var(--radius);font-weight:500}.notice{background:var(--success-bg);border-color:#aceebb;color:#116329}.error{background:var(--danger-bg);border-color:#ffcecb;color:#82071e}.section{padding:24px 32px 0}.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;padding-bottom:8px;border-bottom:1px solid var(--border-muted);margin-bottom:16px}.section-head p:not(.eyebrow){margin-top:4px;color:var(--muted);max-width:760px}.board-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px}.board{padding:0}.card-head{display:flex;justify-content:space-between;gap:16px;padding:16px;border-bottom:1px solid var(--border-muted)}.card-head .url{margin-top:3px;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}.badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:999px;background:#eaeef2;color:var(--muted);font-size:12px;font-weight:600;text-transform:capitalize}.status-running{background:var(--success-bg);color:var(--success)}.status-error,.status-missing{background:var(--danger-bg);color:var(--danger)}.status-partial{background:var(--warning-bg);color:var(--warning)}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0;padding:12px 16px;gap:0;border-bottom:1px solid var(--border-muted)}.facts div{min-width:0;padding-right:12px}.facts dt{color:var(--subtle);font-size:12px}.facts dd{margin:2px 0 0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.compact{padding:12px 16px;border-bottom:1px solid var(--border-muted)}.subsection-title{padding:12px 16px 0;color:var(--muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.seed-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:end;padding:10px 16px 16px;border-bottom:1px solid var(--border-muted)}.mounted-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0}.mounted{padding:14px 16px}.mounted + .mounted{border-left:1px solid var(--border-muted)}.mounted h4{margin-bottom:8px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--border-muted)}.row:first-of-type{border-top:0}.row span{min-width:0}.row strong,.row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row small{color:var(--subtle);font-size:12px}.settings-form{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px}.settings-form h3{grid-column:1/-1}.compact-form{grid-template-columns:repeat(4,minmax(0,1fr))}.stack-form{grid-template-columns:1fr 1fr}.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.form-actions{grid-column:1/-1;display:flex;justify-content:flex-end;padding-top:4px}.field{display:grid;gap:6px;color:var(--text);font-weight:600}.field span{font-size:13px}.field input,.field select{width:100%;height:32px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);padding:5px 8px;font:14px/20px inherit}.field input[name=source],td:last-child{font-family:ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;font-size:12px}.toggle{display:grid;grid-template-columns:36px minmax(0,1fr);gap:10px;align-items:center;align-self:end;min-height:32px;color:var(--text);font-weight:600}.toggle input{position:absolute;opacity:0;pointer-events:none}.toggle span{position:relative;width:36px;height:20px;border:1px solid var(--border);border-radius:999px;background:#eaeef2;transition:background .12s ease}.toggle span:before{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(27,31,36,.25);transition:transform .12s ease}.toggle input:checked + span{background:var(--accent);border-color:var(--accent)}.toggle input:checked + span:before{transform:translateX(16px)}.toggle input:focus-visible + span{box-shadow:var(--focus)}.table-wrap{overflow:auto;border:1px solid var(--border-muted);border-radius:var(--radius);background:#fff}table{width:100%;min-width:920px;border-collapse:collapse}th,td{padding:9px 12px;border-bottom:1px solid var(--border-muted);text-align:left;vertical-align:top}th{background:var(--row);color:var(--muted);font-size:12px;font-weight:600}tr:hover td{background:#fbfcfd}tr:last-child td{border-bottom:0}.actions-column{width:1%;white-space:nowrap}.source-remove{display:flex;justify-content:flex-end}.empty{display:grid;gap:3px;padding:18px;border:1px dashed var(--border);border-radius:var(--radius);background:#fff;color:var(--muted)}.empty strong{color:var(--text)}.muted{color:var(--subtle)}.terminal{overflow:hidden;border:1px solid #30363d;border-radius:var(--radius);background:var(--terminal);box-shadow:var(--shadow)}.terminal-bar{display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;border-bottom:1px solid #30363d;background:#161b22;color:#8b949e;font-size:12px}.terminal-bar span{width:10px;height:10px;border-radius:50%;background:#484f58}.terminal-bar span:first-child{background:#f85149}.terminal-bar span:nth-child(2){background:#d29922}.terminal-bar span:nth-child(3){background:#3fb950}.terminal-bar strong{margin-left:6px;font-weight:600}.progress{display:none;height:2px;background:#21262d;overflow:hidden}.progress span{display:block;width:38%;height:100%;background:var(--accent);animation:progress 1.1s ease-in-out infinite}.is-processing .progress{display:block}.activity-log{height:300px;overflow:auto;margin:0;padding:14px;color:#e6edf3;background:var(--terminal);font:12px/1.55 ui-monospace,SFMono-Regular,SFMono,Consolas,"Liberation Mono",Menlo,monospace;white-space:pre-wrap}.busy{position:fixed;right:20px;bottom:20px;display:none;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:#fff;color:var(--text);box-shadow:0 8px 24px rgba(140,149,159,.25);z-index:20}.busy.is-active{display:flex}.spinner{width:16px;height:16px;border:2px solid #d0d7de;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes progress{0%{transform:translateX(-110%)}100%{transform:translateX(285%)}}@media(max-width:1080px){.app{grid-template-columns:1fr}.sidebar{position:relative;height:auto;border-right:0;border-bottom:1px solid var(--border-muted)}.sidebar nav{display:flex;overflow:auto}.sidebar a{white-space:nowrap}.status-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}}@media(max-width:760px){.topbar{flex-direction:column;padding:20px}.content{max-width:none}.section,.status-strip,.toast-stack{padding-left:20px;padding-right:20px}.board-grid{grid-template-columns:1fr}.settings-form,.compact-form,.stack-form,.seed-form{grid-template-columns:1fr}.facts{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:10px}.mounted-grid{grid-template-columns:1fr}.mounted + .mounted{border-left:0;border-top:1px solid var(--border-muted)}}'; + extract($data, EXTR_SKIP); + ob_start(); + require $path; + return (string) ob_get_clean(); } - private function js(): string + private function e($value): string { - return "const dashboard=document.getElementById('dashboard');const busy=document.getElementById('busy');function setProcessing(active){busy.classList.toggle('is-active',active);document.documentElement.classList.toggle('is-processing',active)}function bindAjax(){document.querySelectorAll('form[data-ajax]').forEach(form=>{if(form.dataset.bound)return;form.dataset.bound='1';form.addEventListener('submit',async event=>{event.preventDefault();if(form.dataset.confirm&&!window.confirm(form.dataset.confirm))return;const submitter=event.submitter;const original=submitter?submitter.textContent:'';if(submitter){submitter.disabled=true;submitter.textContent='Working...'}setProcessing(true);try{const response=await fetch(location.href,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:new FormData(form)});const data=await response.json();dashboard.innerHTML=data.html;bindAjax();scrollLog();}catch(error){alert('Request failed: '+error.message);}finally{setProcessing(false);if(submitter){submitter.disabled=false;submitter.textContent=original;}}});});document.querySelectorAll('[data-clear-log]').forEach(button=>{if(button.dataset.bound)return;button.dataset.bound='1';button.addEventListener('click',()=>{const log=document.getElementById('activity-log');if(log)log.textContent='No command output yet.';});});}function scrollLog(){const log=document.getElementById('activity-log');if(log)log.scrollTop=log.scrollHeight;}bindAjax();scrollLog();"; + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } private function isAjax(): bool diff --git a/src/QuickInstall/Sandbox/Web/templates/dashboard.php b/src/QuickInstall/Sandbox/Web/templates/dashboard.php new file mode 100644 index 00000000..5da83888 --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/templates/dashboard.php @@ -0,0 +1,222 @@ +
+ +
+ e($metric['label']) ?> + e($metric['value']) ?> + e($metric['detail']) ?> + e($metric['description']) ?> +
+ +
+ + +
+ +

e($notice) ?>

+ + +

e($error) ?>

+ +
+ + +
+
+
+

Runtime

+

Boards

+

Start, stop, seed, and inspect local Docker-backed phpBB installs.

+
+
+ +
+ No boards created + Create a board below to generate its compose runtime and workspace files. +
+ +
+ + +
+
+
+

e($name) ?>

+

e($board['url'] ?? '') ?>

+
+ e($status) ?> +
+
+
phpBB
e($board['phpbb'] ?? '') ?>
+
PHP
e($board['php'] ?? '') ?>
+
Database
e($board['db'] ?? '') ?>
+
Populate
e($board['populate'] ?? 'none') ?>
+
+
+ Open +
+ + + +
+
+ + + +
+
+ + + +
+
+
Seed content
+
+ + + + + + +
+
+ +
+

e($mountGroup[0]) ?>

+ +

None mounted

+ + +
+ e($item['name']) ?>e($item['mode']) ?> + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
+

Provision

+

Create board

+

Choose the phpBB source, runtime, port, and optional seed preset.

+
+
+
+ + + + + + + + + +
+
+
+ +
+
+
+

Mounts

+

Customisations

+

Bind or copy local extension and style work into a board.

+
+
+
+ +
+

e($mountForm[1]) ?>

+ + + + + + +
+
+ +
+
+ +
+
+
+

Source cache

+

Sources

+

Fetch and inspect phpBB sources used by boards.

+
+
+
+ + + + + +
+
+ +
No sources registeredFetching a source will populate the local .qi source cache.
+ +
+ + + + + + + + + + + + + + + + +
SourceVersionStatusDownloadedUsed byPathActions
e($sourceKey) ?>e($source['version'] ?? '') ?>e($source['status'] ?? '-') ?>e($usedBy ? implode(', ', $usedBy) : '-') ?>e($source['path'] ?? '') ?> +
+ + + + +
+
+
+ +
+ +
+
+
+

Command output

+

Activity log

+

Docker, Composer, and seed output from the latest action.

+
+ +
+
+
quickinstall sandbox
+ +
e($output) ?>
+
+
diff --git a/src/QuickInstall/Sandbox/Web/templates/layout.php b/src/QuickInstall/Sandbox/Web/templates/layout.php new file mode 100644 index 00000000..ee055e5e --- /dev/null +++ b/src/QuickInstall/Sandbox/Web/templates/layout.php @@ -0,0 +1,50 @@ + + + + + + QuickInstall Sandbox + + + + +
+ +
+
+
+

Local admin UI

+

Sandbox workspace

+

Manage disposable phpBB boards backed by the same Docker services as the CLI.

+
+
+ + +
+
+
+ +
+
+
+
+
+ Processing sandbox action... +
+ + diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index 9a8c21ad..86f6bcf5 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -66,6 +66,9 @@ public function testRenderShowsCoreSandboxWorkflows(): void self::assertStringContainsString('source_remove', $html); self::assertStringContainsString('data-ajax', $html); self::assertStringContainsString('activity-log', $html); + self::assertStringContainsString('/assets/sandbox-ui.css', $html); + self::assertStringContainsString('/assets/sandbox-ui.js', $html); + self::assertStringNotContainsString('