From 4cc41c1fe46595bcb396a4b3bb9a5abf16eca584 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 2 Jul 2026 10:43:35 -0400 Subject: [PATCH 01/42] fix(sync-plugin-url): correct escaping in sed command for pluginURL update --- .github/workflows/sync-plugin-url.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-plugin-url.yml b/.github/workflows/sync-plugin-url.yml index d2b38d9b..3f59717a 100644 --- a/.github/workflows/sync-plugin-url.yml +++ b/.github/workflows/sync-plugin-url.yml @@ -80,7 +80,7 @@ jobs: if [[ "$CURRENT_URL_BRANCH" != "$BRANCH" ]]; then echo "Branch mismatch - fixing pluginURL..." - sed -i -E "s#^##" "$PLG_FILE" + sed -i -E "s#^##" "$PLG_FILE" EXPECTED_LINE="" if ! grep -Fxq "$EXPECTED_LINE" "$PLG_FILE"; then From 1559b2562a67aa79986652a7961e72af4e762185 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 2 Jul 2026 20:59:28 -0400 Subject: [PATCH 02/42] feat(column-layout): implement shared column layout and visibility helpers --- .../compose.manager/include/ColumnLayout.php | 338 ++++++++++++++++++ .../compose.manager/include/ComposeList.php | 64 ++-- .../include/ComposeManager.php | 55 +-- source/compose.manager/include/Exec.php | 168 ++------- .../javascript/composeColumnCustomizer.js | 147 ++++---- 5 files changed, 520 insertions(+), 252 deletions(-) create mode 100644 source/compose.manager/include/ColumnLayout.php diff --git a/source/compose.manager/include/ColumnLayout.php b/source/compose.manager/include/ColumnLayout.php new file mode 100644 index 00000000..0ca44d75 --- /dev/null +++ b/source/compose.manager/include/ColumnLayout.php @@ -0,0 +1,338 @@ + + width CSS vars) + * - ComposeList.php (server-rendered stack rows in the saved order) + * + * By resolving the saved layout server-side, the stack table renders in the + * user's chosen order/visibility on first paint — the client customizer's + * reapply() then becomes a no-op instead of visibly reordering after load. + * + * Server-side helpers in this file also export the client model bootstrap so + * defaults/labels/weights are sourced from one place (avoid PHP/JS drift). + */ + +if (!defined('COMPOSE_COLUMN_PREF_FILE')) { + define('COMPOSE_COLUMN_PREF_FILE', '/boot/config/plugins/compose.manager/column_visibility.json'); +} + +if (!function_exists('compose_column_defaults')) { + /** + * Default visibility for every toggleable column, per scope. + * Key order also defines the canonical fallback column order. + */ + function compose_column_defaults(): array + { + return [ + 'stack' => [ + 'update' => true, + 'containers' => true, + 'uptime' => true, + 'health' => true, + 'cpu' => true, + 'memory' => true, + 'net_io' => false, + 'block_io' => false, + 'description' => true, + 'path' => true, + ], + 'service' => [ + 'update' => true, + 'health' => true, + 'cpu' => true, + 'memory' => true, + 'net_io' => false, + 'block_io' => false, + 'source' => true, + 'tag' => true, + 'net' => true, + 'ip' => true, + 'cport' => true, + 'lport' => true, + ], + ]; + } +} + +if (!function_exists('compose_column_width_weights')) { + /** + * Relative width weights for the stack table. + * Arrow + icon are fixed-px and excluded here; + * name and autostart are structural and always visible. + */ + function compose_column_width_weights(): array + { + return [ + 'name' => 23, + 'update' => 16, + 'containers' => 8, + 'uptime' => 9, + 'health' => 9, + 'cpu' => 10, + 'memory' => 13, + 'net_io' => 10, + 'block_io' => 10, + 'description' => 14, + 'path' => 12, + 'autostart' => 8, + ]; + } +} + +if (!function_exists('compose_stack_column_meta')) { + /** + * Header label + class for each toggleable stack column, in canonical + * (default) order. + */ + function compose_stack_column_meta(): array + { + return [ + 'update' => ['label' => 'Update', 'thClass' => 'col-update'], + 'containers' => ['label' => 'Containers', 'thClass' => 'col-containers'], + 'uptime' => ['label' => 'Uptime', 'thClass' => 'col-uptime'], + 'health' => ['label' => 'Health', 'thClass' => 'col-health'], + 'cpu' => ['label' => 'CPU', 'thClass' => 'cm-advanced col-cpu'], + 'memory' => ['label' => 'Memory', 'thClass' => 'cm-advanced col-memory'], + 'net_io' => ['label' => 'Net I/O', 'thClass' => 'cm-advanced col-net_io'], + 'block_io' => ['label' => 'Disk I/O', 'thClass' => 'cm-advanced col-block_io'], + 'description' => ['label' => 'Description', 'thClass' => 'cm-advanced col-description'], + 'path' => ['label' => 'Path', 'thClass' => 'cm-advanced col-path'], + ]; + } +} + +if (!function_exists('compose_service_column_meta')) { + /** + * Display labels for each toggleable service column, in canonical order. + */ + function compose_service_column_meta(): array + { + return [ + 'update' => ['label' => 'Update'], + 'health' => ['label' => 'Health'], + 'cpu' => ['label' => 'CPU %'], + 'memory' => ['label' => 'Memory'], + 'net_io' => ['label' => 'Network I/O'], + 'block_io' => ['label' => 'Disk I/O'], + 'source' => ['label' => 'Source'], + 'tag' => ['label' => 'Tag'], + 'net' => ['label' => 'Network'], + 'ip' => ['label' => 'IP'], + 'cport' => ['label' => 'Container Port'], + 'lport' => ['label' => 'LAN IP:Port'], + ]; + } +} + +if (!function_exists('compose_column_client_model')) { + /** + * Bootstrap payload consumed by composeColumnCustomizer.js. + * This keeps labels/defaults/weights in one canonical source. + */ + function compose_column_client_model(): array + { + $stackLabels = []; + foreach (compose_stack_column_meta() as $key => $meta) { + $stackLabels[$key] = (string)($meta['label'] ?? $key); + } + + $serviceLabels = []; + foreach (compose_service_column_meta() as $key => $meta) { + $serviceLabels[$key] = (string)($meta['label'] ?? $key); + } + + return [ + 'stackCols' => $stackLabels, + 'serviceCols' => $serviceLabels, + 'defaults' => compose_column_defaults(), + 'stackWidthWeights' => compose_column_width_weights(), + 'stackAlwaysVisible' => [ + 'name' => true, + 'autostart' => true, + ], + ]; + } +} + +if (!function_exists('compose_normalize_column_visibility')) { + /** + * Normalize a raw (possibly partial/untrusted) visibility payload into the + * canonical shape: per-scope booleans plus stackOrder/serviceOrder arrays + * that contain only visible columns, in a de-duplicated order. + * + * Canonical normalization shared by both persisted reads and save requests. + */ + function compose_normalize_column_visibility($saved): array + { + $defaults = compose_column_defaults(); + $normalized = array_merge($defaults, [ + 'stackOrder' => array_keys(array_filter($defaults['stack'])), + 'serviceOrder' => array_keys(array_filter($defaults['service'])), + ]); + + if (!is_array($saved)) { + return $normalized; + } + + foreach (['stack', 'service'] as $scope) { + if (!isset($saved[$scope]) || !is_array($saved[$scope])) { + continue; + } + foreach ($defaults[$scope] as $key => $defaultVal) { + if (array_key_exists($key, $saved[$scope])) { + $rawVal = $saved[$scope][$key]; + $boolVal = filter_var($rawVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($boolVal !== null) { + $normalized[$scope][$key] = $boolVal; + } elseif (function_exists('composeLogger')) { + composeLogger( + 'Ignoring non-boolean column visibility value', + ['scope' => $scope, 'column' => $key, 'valueType' => gettype($rawVal)], + 'user', + 'debug', + 'column-layout' + ); + } + } + } + } + + foreach (['stackOrder', 'serviceOrder'] as $orderKey) { + $scope = $orderKey === 'stackOrder' ? 'stack' : 'service'; + $allowed = array_keys($defaults[$scope]); + $savedOrder = isset($saved[$orderKey]) && is_array($saved[$orderKey]) ? $saved[$orderKey] : []; + $ordered = []; + + foreach ($savedOrder as $col) { + if (in_array($col, $allowed, true) && $normalized[$scope][$col] && !in_array($col, $ordered, true)) { + $ordered[] = $col; + } + } + foreach ($allowed as $col) { + if ($normalized[$scope][$col] && !in_array($col, $ordered, true)) { + $ordered[] = $col; + } + } + + $normalized[$orderKey] = $ordered; + } + + return $normalized; + } +} + +if (!function_exists('compose_read_column_layout')) { + /** + * Read and normalize the saved column layout from disk. Falls back to + * defaults when the preference file is missing or unreadable/invalid. + */ + function compose_read_column_layout(): array + { + $saved = null; + if (is_file(COMPOSE_COLUMN_PREF_FILE)) { + $raw = @file_get_contents(COMPOSE_COLUMN_PREF_FILE); + if ($raw === false) { + if (function_exists('composeLogger')) { + composeLogger( + 'Failed to read column layout preference file; using defaults', + ['file' => COMPOSE_COLUMN_PREF_FILE], + 'user', + 'warning', + 'column-layout' + ); + } + } else { + $decoded = json_decode((string)$raw, true); + if (is_array($decoded)) { + $saved = $decoded; + } elseif (function_exists('composeLogger')) { + composeLogger( + 'Invalid column layout JSON; using defaults', + [ + 'file' => COMPOSE_COLUMN_PREF_FILE, + 'jsonError' => json_last_error_msg(), + ], + 'user', + 'warning', + 'column-layout' + ); + } + } + } + return compose_normalize_column_visibility($saved); + } +} + +if (!function_exists('compose_stack_render_order')) { + /** + * Full DOM render order for the toggleable stack columns: visible columns in + * the saved order first, then any hidden columns in canonical order. Hidden + * columns are still rendered (so the customizer can reveal them without a + * reload); the table's hide-col-* classes control their visibility. + * + * Client reorder/reapply uses this same visible-first ordering model. + */ + function compose_stack_render_order(array $layout): array + { + $order = isset($layout['stackOrder']) && is_array($layout['stackOrder']) ? $layout['stackOrder'] : []; + foreach (array_keys(compose_column_defaults()['stack']) as $col) { + if (!in_array($col, $order, true)) { + $order[] = $col; + } + } + return $order; + } +} + +if (!function_exists('compose_stack_hidden_columns')) { + /** + * List of toggleable stack columns that are currently hidden. Used to emit + * hide-col-* classes on the table server-side. + */ + function compose_stack_hidden_columns(array $layout): array + { + $hidden = []; + foreach (array_keys(compose_column_defaults()['stack']) as $col) { + if (empty($layout['stack'][$col])) { + $hidden[] = $col; + } + } + return $hidden; + } +} + +if (!function_exists('compose_stack_width_fractions')) { + /** + * Compute the per-column width fraction for the stack table. + * Hidden columns get a + * fraction of 0. Keys are the weight keys (name, autostart, toggleables). + */ + function compose_stack_width_fractions(array $layout): array + { + $weights = compose_column_width_weights(); + + // name + autostart are structural (always visible); toggleables per prefs. + $visible = ['name' => true, 'autostart' => true]; + foreach (array_keys(compose_column_defaults()['stack']) as $col) { + $visible[$col] = !empty($layout['stack'][$col]); + } + + $total = 0; + foreach ($weights as $col => $weight) { + if (!empty($visible[$col])) { + $total += $weight; + } + } + + $fractions = []; + foreach ($weights as $col => $weight) { + $fractions[$col] = ($total > 0 && !empty($visible[$col])) ? ($weight / $total) : 0.0; + } + return $fractions; + } +} diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index 2d372f7b..fde55a73 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -7,9 +7,15 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/Defines.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/Util.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/ColumnLayout.php"); $cfg = parse_plugin_cfg($sName); +// Resolve saved column order so rows render in the user's chosen order on first +// paint. Hidden columns are still emitted (hide-col-* on the table controls +// visibility); the client customizer's reapply() becomes a no-op on load. +$stackColumnOrder = compose_stack_render_order(compose_read_column_layout()); + $mode = isset($_GET['mode']) ? trim((string)$_GET['mode']) : 'html'; if ($mode === 'list') { $projects = StackInfo::listProjectFolders($compose_root); @@ -235,53 +241,69 @@ $o .= ""; $o .= ""; + // Toggleable columns are built into a keyed map and emitted below in the + // user's saved order, so rows render correctly on first paint. + $stackCells = []; + // Update column (like Docker tab) - default to "not checked" until update check runs - $o .= ""; + $updateCell = ""; if ($isrunning) { - $o .= " not checked"; + $updateCell .= " not checked"; } else { - $o .= " stopped"; + $updateCell .= " stopped"; } - $o .= ""; + $updateCell .= ""; + $stackCells['update'] = $updateCell; // Containers column (shows running/total) $containersDisplay = $isrunning ? "$runningCount / $containerCount" : "0 / $containerCount"; $containersClass = ($runningCount == $containerCount && $runningCount > 0) ? 'green-text' : ($runningCount > 0 ? 'orange-text' : 'grey-text'); - $o .= "$containersDisplay"; + $stackCells['containers'] = "$containersDisplay"; // Uptime column (both basic and advanced views) $uptimeDisplay = $stackUptime; $uptimeClass = $isrunning ? 'green-text' : 'grey-text'; - $o .= "$uptimeDisplay"; + $stackCells['uptime'] = "$uptimeDisplay"; // Health column (updated from detailed inspect data by frontend; initial fallback here) $healthDisplay = $isrunning ? 'n/a' : 'stopped'; $healthClass = $isrunning ? 'compose-text-muted' : 'grey-text'; - $o .= "$healthDisplay"; + $stackCells['health'] = "$healthDisplay"; // Metric columns (advanced only) - $o .= ""; - $o .= "-"; - $o .= "
"; - $o .= ""; + $cpuCell = ""; + $cpuCell .= "-"; + $cpuCell .= "
"; + $cpuCell .= ""; + $stackCells['cpu'] = $cpuCell; - $o .= ""; - $o .= "-"; - $o .= "
"; - $o .= ""; + $memCell = ""; + $memCell .= "-"; + $memCell .= "
"; + $memCell .= ""; + $stackCells['memory'] = $memCell; - $o .= "-"; - $o .= "-"; + $stackCells['net_io'] = "-"; + $stackCells['block_io'] = "-"; // Description column (advanced only) - $o .= ""; + $descriptionCell = ""; if ($hasInvalidIndirect) { - $o .= "
External compose path unavailable, using local stack path.
"; + $descriptionCell .= "
External compose path unavailable, using local stack path.
"; } - $o .= "$descriptionHtml"; + $descriptionCell .= "$descriptionHtml"; + $stackCells['description'] = $descriptionCell; // Path column (advanced only) - $o .= "$pathHtml"; + $stackCells['path'] = "$pathHtml"; + + // Emit toggleable columns in the saved order (hidden columns still render; + // hide-col-* classes on the table control their visibility). + foreach ($stackColumnOrder as $stackCol) { + if (isset($stackCells[$stackCol])) { + $o .= $stackCells[$stackCol]; + } + } // Auto Start toggle $o .= ""; diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 9359d202..4d45425f 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -7,9 +7,29 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/Defines.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/Util.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/ColumnLayout.php"); // Load plugin config $cfg = parse_plugin_cfg($sName); + +// Resolve the saved column layout so the stack table renders in the user's +// chosen order/visibility/widths on first paint (no client-side reorder snap). +$columnLayout = compose_read_column_layout(); +$columnModel = compose_column_client_model(); +$stackColumnOrder = compose_stack_render_order($columnLayout); +$stackColumnMeta = compose_stack_column_meta(); +$stackHiddenColumns = compose_stack_hidden_columns($columnLayout); +$stackWidthFractions = compose_stack_width_fractions($columnLayout); +composeLogger('Prepared column model bootstrap', [ + 'stackCols' => count($columnModel['stackCols'] ?? []), + 'serviceCols' => count($columnModel['serviceCols'] ?? []), + 'stackVisible' => count($columnLayout['stackOrder'] ?? []), + 'serviceVisible' => count($columnLayout['serviceOrder'] ?? []), +], 'user', 'debug', 'column-layout'); +$stackHideClass = ''; +foreach ($stackHiddenColumns as $hiddenCol) { + $stackHideClass .= ' hide-col-' . $hiddenCol; +} $autoCheckUpdates = ($cfg['AUTO_CHECK_UPDATES'] ?? 'false') === 'true'; $autoCheckDays = floatval($cfg['AUTO_CHECK_UPDATES_DAYS'] ?? '1'); $showComposeOnTop = ($cfg['SHOW_COMPOSE_ON_TOP'] ?? 'false') === 'true'; @@ -78,18 +98,9 @@ function compose_manager_cpu_spec_count($cpuSpec) --cm-col-arrow-px: 24px; --cm-col-icon-px: 48px; --cm-col-fixed-px: calc(var(--cm-col-arrow-px) + var(--cm-col-icon-px)); - --cm-col-name-frac: 0.188524590; - --cm-col-update-frac: 0.131147541; - --cm-col-containers-frac: 0.065573770; - --cm-col-uptime-frac: 0.073770492; - --cm-col-health-frac: 0.073770492; - --cm-col-cpu-frac: 0.081967213; - --cm-col-memory-frac: 0.106557377; - --cm-col-net-io-frac: 0.000000000; - --cm-col-block-io-frac: 0.000000000; - --cm-col-description-frac: 0.114754098; - --cm-col-path-frac: 0.098360656; - --cm-col-autostart-frac: 0.065573770; + $fracVal): ?> + --cm-col--frac: ; + } /* Stabilize header row height across basic/advanced toggle transitions */ @@ -356,7 +367,9 @@ function compose_manager_cpu_spec_count($cpuSpec) composeSystemMemBytes: , composeCpuCount: , comboButtonCss: "", - editorModalCss: "" + editorModalCss: "", + columnModel: , + columnLayout: }; var composeBootstrap = window.composeManagerBootstrap || {}; @@ -399,7 +412,7 @@ function compose_manager_cpu_spec_count($cpuSpec)
- +
- - - - - - - - - - + + + + diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 2ba19248..e7898473 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -2,6 +2,7 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/Defines.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/Util.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/ColumnLayout.php"); require_once("/usr/local/emhttp/plugins/dynamix/include/Wrappers.php"); require_once('/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php'); @@ -160,165 +161,60 @@ function composeResolveContainerIcon(string $containerName, string $service, arr echo json_encode(['result' => 'success', 'config' => $cfg]); break; case 'getColumnVisibility': - $prefFile = '/boot/config/plugins/compose.manager/column_visibility.json'; - $defaults = [ - 'stack' => [ - 'update' => true, - 'containers' => true, - 'uptime' => true, - 'health' => true, - 'cpu' => true, - 'memory' => true, - 'net_io' => false, - 'block_io' => false, - 'description' => true, - 'path' => true, - ], - 'service' => [ - 'update' => true, - 'health' => true, - 'cpu' => true, - 'memory' => true, - 'net_io' => false, - 'block_io' => false, - 'source' => true, - 'tag' => true, - 'net' => true, - 'ip' => true, - 'cport' => true, - 'lport' => true, - ], - ]; - $defaultOrder = [ - 'stackOrder' => array_keys(array_filter($defaults['stack'])), - 'serviceOrder' => array_keys(array_filter($defaults['service'])), - ]; - - $visibility = array_merge($defaults, $defaultOrder); - if (is_file($prefFile)) { - $raw = @file_get_contents($prefFile); - $saved = json_decode((string)$raw, true); - if (is_array($saved)) { - foreach (['stack', 'service'] as $scope) { - if (!isset($saved[$scope]) || !is_array($saved[$scope])) continue; - foreach ($defaults[$scope] as $key => $defaultVal) { - if (array_key_exists($key, $saved[$scope])) { - $visibility[$scope][$key] = (bool)$saved[$scope][$key]; - } - } - } - - foreach (['stackOrder', 'serviceOrder'] as $orderKey) { - if (!isset($saved[$orderKey]) || !is_array($saved[$orderKey])) continue; - - $scope = $orderKey === 'stackOrder' ? 'stack' : 'service'; - $allowed = array_keys($defaults[$scope]); - $normalizedOrder = []; - - foreach ($saved[$orderKey] as $col) { - if (in_array($col, $allowed, true) && $visibility[$scope][$col] && !in_array($col, $normalizedOrder, true)) { - $normalizedOrder[] = $col; - } - } - - foreach ($allowed as $col) { - if ($visibility[$scope][$col] && !in_array($col, $normalizedOrder, true)) { - $normalizedOrder[] = $col; - } - } - - $visibility[$orderKey] = $normalizedOrder; - } - } - } + $visibility = compose_read_column_layout(); + composeLogger('Loaded column visibility layout', [ + 'stackVisible' => count($visibility['stackOrder'] ?? []), + 'serviceVisible' => count($visibility['serviceOrder'] ?? []), + ], 'user', 'debug', 'column-layout'); echo json_encode(['result' => 'success', 'visibility' => $visibility]); break; case 'saveColumnVisibility': - $prefFile = '/boot/config/plugins/compose.manager/column_visibility.json'; + $prefFile = COMPOSE_COLUMN_PREF_FILE; $prefDir = dirname($prefFile); - $defaults = [ - 'stack' => [ - 'update' => true, - 'containers' => true, - 'uptime' => true, - 'health' => true, - 'cpu' => true, - 'memory' => true, - 'net_io' => false, - 'block_io' => false, - 'description' => true, - 'path' => true, - ], - 'service' => [ - 'update' => true, - 'health' => true, - 'cpu' => true, - 'memory' => true, - 'net_io' => false, - 'block_io' => false, - 'source' => true, - 'tag' => true, - 'net' => true, - 'ip' => true, - 'cport' => true, - 'lport' => true, - ], - ]; - $defaultOrder = [ - 'stackOrder' => array_keys(array_filter($defaults['stack'])), - 'serviceOrder' => array_keys(array_filter($defaults['service'])), - ]; $raw = $_POST['visibility'] ?? ''; $parsed = json_decode((string)$raw, true); if (!is_array($parsed)) { + composeLogger('Rejected invalid column visibility payload', [ + 'jsonError' => json_last_error_msg(), + 'payloadLength' => strlen((string)$raw), + ], 'user', 'warning', 'column-layout'); echo json_encode(['result' => 'error', 'message' => 'Invalid visibility payload.']); break; } - $normalized = array_merge($defaults, $defaultOrder); - foreach (['stack', 'service'] as $scope) { - if (!isset($parsed[$scope]) || !is_array($parsed[$scope])) continue; - foreach ($defaults[$scope] as $key => $defaultVal) { - if (array_key_exists($key, $parsed[$scope])) { - $normalized[$scope][$key] = (bool)$parsed[$scope][$key]; - } - } - } - - foreach (['stackOrder', 'serviceOrder'] as $orderKey) { - $scope = $orderKey === 'stackOrder' ? 'stack' : 'service'; - $allowed = array_keys($defaults[$scope]); - $savedOrder = isset($parsed[$orderKey]) && is_array($parsed[$orderKey]) ? $parsed[$orderKey] : []; - $normalizedOrder = []; - - foreach ($savedOrder as $col) { - if (in_array($col, $allowed, true) && $normalized[$scope][$col] && !in_array($col, $normalizedOrder, true)) { - $normalizedOrder[] = $col; - } - } - - foreach ($allowed as $col) { - if ($normalized[$scope][$col] && !in_array($col, $normalizedOrder, true)) { - $normalizedOrder[] = $col; - } - } - - $normalized[$orderKey] = $normalizedOrder; - } + $normalized = compose_normalize_column_visibility($parsed); if (!is_dir($prefDir)) { - @mkdir($prefDir, 0777, true); + if (!@mkdir($prefDir, 0777, true) && !is_dir($prefDir)) { + composeLogger('Failed to create column layout preference directory', [ + 'dir' => $prefDir, + ], 'user', 'error', 'column-layout'); + echo json_encode(['result' => 'error', 'message' => 'Failed to persist column visibility.']); + break; + } } $tmp = $prefFile . '.tmp'; $ok = @file_put_contents($tmp, json_encode($normalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - if ($ok === false || !@rename($tmp, $prefFile)) { + $renameOk = ($ok !== false) ? @rename($tmp, $prefFile) : false; + if ($ok === false || !$renameOk) { + composeLogger('Failed to persist column visibility', [ + 'prefFile' => $prefFile, + 'tmpFile' => $tmp, + 'writeOk' => ($ok !== false), + 'renameOk' => $renameOk, + ], 'user', 'error', 'column-layout'); @unlink($tmp); echo json_encode(['result' => 'error', 'message' => 'Failed to persist column visibility.']); break; } + composeLogger('Saved column visibility layout', [ + 'stackVisible' => count($normalized['stackOrder'] ?? []), + 'serviceVisible' => count($normalized['serviceOrder'] ?? []), + 'prefFile' => $prefFile, + ], 'user', 'debug', 'column-layout'); echo json_encode(['result' => 'success', 'visibility' => $normalized]); break; case 'getPersistentContainerCache': diff --git a/source/compose.manager/javascript/composeColumnCustomizer.js b/source/compose.manager/javascript/composeColumnCustomizer.js index dec9d03f..f1eb946e 100644 --- a/source/compose.manager/javascript/composeColumnCustomizer.js +++ b/source/compose.manager/javascript/composeColumnCustomizer.js @@ -5,63 +5,24 @@ (function() { 'use strict'; - var STACK_COLS = { - update: 'Update', - containers: 'Containers', - uptime: 'Uptime', - health: 'Health', - cpu: 'CPU %', - memory: 'Memory', - net_io: 'Network I/O', - block_io: 'Disk I/O', - description: 'Description', - path: 'Path' - }; + var composeBootstrap = window.composeManagerBootstrap || {}; + var columnModel = composeBootstrap.columnModel || {}; - var SERVICE_COLS = { - update: 'Update', - health: 'Health', - cpu: 'CPU %', - memory: 'Memory', - net_io: 'Network I/O', - block_io: 'Disk I/O', - source: 'Source', - tag: 'Tag', - net: 'Network', - ip: 'IP', - cport: 'Container Port', - lport: 'LAN IP:Port' - }; + var STACK_COLS = $.extend({}, columnModel.stackCols || {}); + var SERVICE_COLS = $.extend({}, columnModel.serviceCols || {}); var defaults = { - stack: { - update: true, - containers: true, - uptime: true, - health: true, - cpu: true, - memory: true, - net_io: false, - block_io: false, - description: true, - path: true - }, - service: { - update: true, - health: true, - cpu: true, - memory: true, - net_io: false, - block_io: false, - source: true, - tag: true, - net: true, - ip: true, - cport: true, - lport: true - } + stack: $.extend({}, (columnModel.defaults || {}).stack || {}), + service: $.extend({}, (columnModel.defaults || {}).service || {}) }; + if (!Object.keys(defaults.stack).length || !Object.keys(defaults.service).length) { + if (typeof composeLogger === 'function') { + composeLogger('Column model bootstrap missing; customizer disabled for this page load', null, 'user', 'warning', 'column-layout'); + } + return; + } + var prefs = { stack: $.extend({}, defaults.stack), service: $.extend({}, defaults.service), @@ -69,25 +30,26 @@ serviceOrder: Object.keys(defaults.service).filter(function(key) { return defaults.service[key]; }) }; - var STACK_WIDTH_WEIGHTS = { - name: 23, - update: 16, - containers: 8, - uptime: 9, - health: 9, - cpu: 10, - memory: 13, - net_io: 10, - block_io: 10, - description: 14, - path: 12, - autostart: 8 - }; + // Seed from the server-provided layout synchronously so the very first + // reapply()/reorder pass uses the saved order — the table is already + // server-rendered in this order, so reapply is a no-op (no visible snap). + // The async fetchPrefs() below simply refreshes the same values. + try { + var bootstrapLayout = composeBootstrap.columnLayout; + if (bootstrapLayout) { + prefs = normalizePrefs(bootstrapLayout); + if (typeof composeLogger === 'function') { + composeLogger('Applied bootstrap column layout', { + stackVisible: (prefs.stackOrder || []).length, + serviceVisible: (prefs.serviceOrder || []).length + }, 'user', 'debug', 'column-layout'); + } + } + } catch (e) { /* fall back to defaults */ } - var STACK_DEFAULT_VISIBLE = { - name: true, - autostart: true - }; + var STACK_WIDTH_WEIGHTS = $.extend({}, columnModel.stackWidthWeights || {}); + + var STACK_DEFAULT_VISIBLE = $.extend({}, columnModel.stackAlwaysVisible || {}); var STACK_CELL_CLASS_MAP = { update: 'col-update', @@ -132,6 +94,17 @@ lport: 'ct-col-lport-cell' }; + function toBooleanLike(value, fallback) { + if (value === true || value === false) return value; + if (value === 1 || value === 0) return value === 1; + if (typeof value === 'string') { + var normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '') return false; + } + return !!fallback; + } + function normalizePrefs(incoming) { var out = { stack: $.extend({}, defaults.stack), @@ -145,7 +118,7 @@ if (!incoming[scope] || typeof incoming[scope] !== 'object') return; Object.keys(out[scope]).forEach(function(key) { if (Object.prototype.hasOwnProperty.call(incoming[scope], key)) { - out[scope][key] = !!incoming[scope][key]; + out[scope][key] = toBooleanLike(incoming[scope][key], out[scope][key]); } }); }); @@ -535,12 +508,26 @@ } if (parsed && parsed.result === 'success' && parsed.visibility) { prefs = normalizePrefs(parsed.visibility); + if (typeof composeLogger === 'function') { + composeLogger('Fetched column visibility preferences', { + stackVisible: (prefs.stackOrder || []).length, + serviceVisible: (prefs.serviceOrder || []).length + }, 'user', 'debug', 'column-layout'); + } } else { prefs = normalizePrefs(null); + if (typeof composeLogger === 'function') { + composeLogger('Column visibility fetch returned fallback payload; using defaults', { + result: parsed && parsed.result ? parsed.result : 'invalid' + }, 'user', 'debug', 'column-layout'); + } } if (typeof cb === 'function') cb(); }).fail(function() { prefs = normalizePrefs(null); + if (typeof composeLogger === 'function') { + composeLogger('Column visibility fetch failed; using defaults', null, 'user', 'warning', 'column-layout'); + } if (typeof cb === 'function') cb(); }); } @@ -560,9 +547,22 @@ } if (parsed && parsed.result === 'success' && parsed.visibility) { prefs = normalizePrefs(parsed.visibility); + if (typeof composeLogger === 'function') { + composeLogger('Saved column visibility preferences', { + stackVisible: (prefs.stackOrder || []).length, + serviceVisible: (prefs.serviceOrder || []).length + }, 'user', 'debug', 'column-layout'); + } + } else if (typeof composeLogger === 'function') { + composeLogger('Column visibility save returned non-success payload', { + result: parsed && parsed.result ? parsed.result : 'invalid' + }, 'user', 'warning', 'column-layout'); } if (typeof cb === 'function') cb(); }).fail(function() { + if (typeof composeLogger === 'function') { + composeLogger('Column visibility save request failed', null, 'user', 'warning', 'column-layout'); + } if (typeof cb === 'function') cb(); }); } @@ -662,5 +662,10 @@ $(function() { window.composeColCustomizer.init(); + $(document).on('composeListRefreshed.composeColumnCustomizer', function() { + if (window.composeColCustomizer && typeof window.composeColCustomizer.reapply === 'function') { + window.composeColCustomizer.reapply(); + } + }); }); })(); From 296a34f453f5ebdd59b57920531cf1f8c49d2ef7 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 2 Jul 2026 21:17:15 -0400 Subject: [PATCH 03/42] chore(columns): cleanup advanced/basic view mode references and legacy logic --- README.md | 2 +- .../compose.manager/include/ColumnLayout.php | 12 +-- .../compose.manager/include/ComposeList.php | 22 +++--- .../include/ComposeManager.php | 14 ++-- .../javascript/composeManagerMain.js | 77 +++++++++---------- source/compose.manager/sheets/ComboButton.css | 2 +- tests/unit/ComposeListHtmlTest.php | 11 +-- 7 files changed, 67 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 18a494d5..46eb9ee0 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The built-in editor provides multiple tabs for managing your compose stack: - **External Paths** - Compose files and env files can live outside the default projects folder (external compose path and env path per stack). - **Override File Management** - Centralized management of override files (`compose.override.yaml`, `compose.override.yml`, `docker-compose.override.yaml`, `docker-compose.override.yml`) with service rename handling. - **Stack Editor** - Full-screen modal editor with four tabs: Compose (YAML with live validation), ENV, Web UI Labels (icon/WebUI/shell per service via override file), and Settings (name, description, icon URL, WebUI URL, default profile, external paths). Ctrl+S to save, Esc to close. -- **Basic / Advanced View Toggle** - Toggle between a compact view and an advanced view exposing additional columns (SHA diffs, force-update links) — scoped to the Compose tab to avoid affecting the Docker tab. +- **Column Customizer & Layout** - Choose which stack and service columns are visible and in what order; extra detail fields (for example SHA snippets and force-update links) follow your saved layout in the Compose tab. - **Compose File Discovery** - Automatically detects all four standard compose file names (`compose.yaml`, `docker-compose.yaml`, `compose.yml`, `docker-compose.yml`). - **Filename Preservation** - Existing compose and override filenames are preserved; upgrades do not auto-rename your stack files. - **Build Stack Support** - Stacks with a `build:` section in the compose file are detected automatically; context menu and update labels adapt ("Build", "Build & Up", "Update & Rebuild"). diff --git a/source/compose.manager/include/ColumnLayout.php b/source/compose.manager/include/ColumnLayout.php index 0ca44d75..5d6d7d39 100644 --- a/source/compose.manager/include/ColumnLayout.php +++ b/source/compose.manager/include/ColumnLayout.php @@ -96,12 +96,12 @@ function compose_stack_column_meta(): array 'containers' => ['label' => 'Containers', 'thClass' => 'col-containers'], 'uptime' => ['label' => 'Uptime', 'thClass' => 'col-uptime'], 'health' => ['label' => 'Health', 'thClass' => 'col-health'], - 'cpu' => ['label' => 'CPU', 'thClass' => 'cm-advanced col-cpu'], - 'memory' => ['label' => 'Memory', 'thClass' => 'cm-advanced col-memory'], - 'net_io' => ['label' => 'Net I/O', 'thClass' => 'cm-advanced col-net_io'], - 'block_io' => ['label' => 'Disk I/O', 'thClass' => 'cm-advanced col-block_io'], - 'description' => ['label' => 'Description', 'thClass' => 'cm-advanced col-description'], - 'path' => ['label' => 'Path', 'thClass' => 'cm-advanced col-path'], + 'cpu' => ['label' => 'CPU', 'thClass' => 'col-cpu'], + 'memory' => ['label' => 'Memory', 'thClass' => 'col-memory'], + 'net_io' => ['label' => 'Net I/O', 'thClass' => 'col-net_io'], + 'block_io' => ['label' => 'Disk I/O', 'thClass' => 'col-block_io'], + 'description' => ['label' => 'Description', 'thClass' => 'col-description'], + 'path' => ['label' => 'Path', 'thClass' => 'col-path'], ]; } } diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index fde55a73..d42bee1d 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -235,7 +235,7 @@ ], 'user', 'debug', 'stack-list'); $o .= " "; } - $o .= "
"; + $o .= "
"; $o .= "Project: $projectHtml"; $o .= "
"; $o .= ""; @@ -260,7 +260,7 @@ $containersClass = ($runningCount == $containerCount && $runningCount > 0) ? 'green-text' : ($runningCount > 0 ? 'orange-text' : 'grey-text'); $stackCells['containers'] = "
"; - // Uptime column (both basic and advanced views) + // Uptime column (always visible) $uptimeDisplay = $stackUptime; $uptimeClass = $isrunning ? 'green-text' : 'grey-text'; $stackCells['uptime'] = ""; @@ -270,32 +270,32 @@ $healthClass = $isrunning ? 'compose-text-muted' : 'grey-text'; $stackCells['health'] = ""; - // Metric columns (advanced only) - $cpuCell = ""; $stackCells['cpu'] = $cpuCell; - $memCell = ""; $stackCells['memory'] = $memCell; - $stackCells['net_io'] = ""; - $stackCells['block_io'] = ""; + $stackCells['net_io'] = ""; + $stackCells['block_io'] = ""; - // Description column (advanced only) - $descriptionCell = ""; $stackCells['description'] = $descriptionCell; - // Path column (advanced only) - $stackCells['path'] = ""; + // Path column (toggleable via column customizer) + $stackCells['path'] = ""; // Emit toggleable columns in the saved order (hidden columns still render; // hide-col-* classes on the table control their visibility). diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 4d45425f..e739adb0 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -85,16 +85,16 @@ function compose_manager_cpu_spec_count($cpuSpec) // This improves page load time by deferring expensive docker commands ?> -. */ ?>
@@ -409,16 +422,10 @@ function compose_manager_cpu_spec_count($cpuSpec) StackUpdateContainersUptimeHealthCPUMemoryNet I/ODisk I/ODescriptionPath Autostart
$containersDisplay$uptimeDisplay$healthDisplay"; + // Metric columns (toggleable via column customizer) + $cpuCell = ""; $cpuCell .= "-"; $cpuCell .= "
"; $cpuCell .= "
"; + $memCell = ""; $memCell .= "-"; $memCell .= "
"; $memCell .= "
----"; + // Description column (toggleable via column customizer) + $descriptionCell = ""; if ($hasInvalidIndirect) { $descriptionCell .= "
External compose path unavailable, using local stack path.
"; } $descriptionCell .= "$descriptionHtml
$pathHtml$pathHtml