diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 9030c8e8ec..035349b728 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -3,7 +3,10 @@ namespace App\Livewire; use App\Models\Application; +use App\Models\CloudInitScript; +use App\Models\CloudProviderToken; use App\Models\Environment; +use App\Models\IntegrationToken; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -16,6 +19,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Livewire\Component; @@ -252,7 +256,7 @@ class GlobalSearch extends Component // Try to get from Redis cache first $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); - $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + $resources = Cache::remember($cacheKey, 300, function () { $items = collect(); $team = auth()->user()->currentTeam(); @@ -617,143 +621,8 @@ class GlobalSearch extends Component ]; }); - // Add navigation routes - $navigation = collect([ - [ - 'name' => 'Dashboard', - 'type' => 'navigation', - 'description' => 'Go to main dashboard', - 'link' => route('dashboard'), - 'search_text' => 'dashboard home main overview', - ], - [ - 'name' => 'Servers', - 'type' => 'navigation', - 'description' => 'View all servers', - 'link' => route('server.index'), - 'search_text' => 'servers all list view', - ], - [ - 'name' => 'Projects', - 'type' => 'navigation', - 'description' => 'View all projects', - 'link' => route('project.index'), - 'search_text' => 'projects all list view', - ], - [ - 'name' => 'Destinations', - 'type' => 'navigation', - 'description' => 'View all destinations', - 'link' => route('destination.index'), - 'search_text' => 'destinations docker networks', - ], - [ - 'name' => 'Security', - 'type' => 'navigation', - 'description' => 'Manage private keys and API tokens', - 'link' => route('security.private-key.index'), - 'search_text' => 'security private keys ssh api tokens cloud-init scripts', - ], - [ - 'name' => 'Cloud-Init Scripts', - 'type' => 'navigation', - 'description' => 'Manage reusable cloud-init scripts', - 'link' => route('security.cloud-init-scripts'), - 'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup', - ], - [ - 'name' => 'Sources', - 'type' => 'navigation', - 'description' => 'Manage GitHub apps and Git sources', - 'link' => route('source.all'), - 'search_text' => 'sources github apps git repositories', - ], - [ - 'name' => 'Storages', - 'type' => 'navigation', - 'description' => 'Manage S3 storage for backups', - 'link' => route('storage.index'), - 'search_text' => 'storages s3 backups', - ], - [ - 'name' => 'Shared Variables', - 'type' => 'navigation', - 'description' => 'View all shared variables', - 'link' => route('shared-variables.index'), - 'search_text' => 'shared variables environment all', - ], - [ - 'name' => 'Team Shared Variables', - 'type' => 'navigation', - 'description' => 'Manage team-wide shared variables', - 'link' => route('shared-variables.team.index'), - 'search_text' => 'shared variables team environment', - ], - [ - 'name' => 'Project Shared Variables', - 'type' => 'navigation', - 'description' => 'Manage project shared variables', - 'link' => route('shared-variables.project.index'), - 'search_text' => 'shared variables project environment', - ], - [ - 'name' => 'Environment Shared Variables', - 'type' => 'navigation', - 'description' => 'Manage environment shared variables', - 'link' => route('shared-variables.environment.index'), - 'search_text' => 'shared variables environment', - ], - [ - 'name' => 'Tags', - 'type' => 'navigation', - 'description' => 'View resources by tags', - 'link' => route('tags.show'), - 'search_text' => 'tags labels organize', - ], - [ - 'name' => 'Terminal', - 'type' => 'navigation', - 'description' => 'Access server terminal', - 'link' => route('terminal'), - 'search_text' => 'terminal ssh console shell command line', - ], - [ - 'name' => 'Profile', - 'type' => 'navigation', - 'description' => 'Manage your profile and preferences', - 'link' => route('profile'), - 'search_text' => 'profile account user settings preferences', - ], - [ - 'name' => 'Team', - 'type' => 'navigation', - 'description' => 'Manage team members and settings', - 'link' => route('team.index'), - 'search_text' => 'team settings members users invitations', - ], - [ - 'name' => 'Notifications', - 'type' => 'navigation', - 'description' => 'Configure email, Discord, Telegram notifications', - 'link' => route('notifications.email'), - 'search_text' => 'notifications alerts email discord telegram slack pushover', - ], - ]); - - // Add instance settings only for self-hosted and root team - if (! isCloud() && $team->id === 0) { - $navigation->push([ - 'name' => 'Settings', - 'type' => 'navigation', - 'description' => 'Instance settings and configuration', - 'link' => route('settings.index'), - 'search_text' => 'settings configuration instance', - ]); - } - // Merge all collections - $items = $items->merge($navigation) - ->merge($applications) + $items = $items->merge($applications) ->merge($services) ->merge($databases) ->merge($servers) @@ -762,6 +631,307 @@ class GlobalSearch extends Component return $items->toArray(); }); + + // Navigation depends on the user's permissions, so it is not part of the team-wide cache + $this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all(); + } + + private function navigationItems(): Collection + { + $user = auth()->user(); + $team = $user->currentTeam(); + + // Add navigation routes + $navigation = collect([ + [ + 'name' => 'Dashboard', + 'type' => 'navigation', + 'description' => 'Go to main dashboard', + 'link' => route('dashboard'), + 'search_text' => 'dashboard home main overview', + ], + [ + 'name' => 'Servers', + 'type' => 'navigation', + 'description' => 'View all servers', + 'link' => route('server.index'), + 'search_text' => 'servers all list view', + ], + [ + 'name' => 'Projects', + 'type' => 'navigation', + 'description' => 'View all projects', + 'link' => route('project.index'), + 'search_text' => 'projects all list view', + ], + [ + 'name' => 'Destinations', + 'type' => 'navigation', + 'description' => 'View all destinations', + 'link' => route('destination.index'), + 'search_text' => 'destinations docker networks', + ], + [ + 'name' => 'Security', + 'type' => 'navigation', + 'description' => 'Manage private keys and API tokens', + 'link' => route('security.private-key.index'), + 'search_text' => 'security private keys ssh api tokens cloud-init scripts', + ], + $user->can('viewAny', CloudInitScript::class) ? [ + 'name' => 'Cloud-Init Scripts', + 'type' => 'navigation', + 'description' => 'Manage reusable cloud-init scripts', + 'link' => route('security.cloud-init-scripts'), + 'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup', + ] : null, + [ + 'name' => 'Sources', + 'type' => 'navigation', + 'description' => 'Manage GitHub apps and Git sources', + 'link' => route('source.all'), + 'search_text' => 'sources github apps git repositories', + ], + [ + 'name' => 'Storages', + 'type' => 'navigation', + 'description' => 'Manage S3 storage for backups', + 'link' => route('storage.index'), + 'search_text' => 'storages s3 backups', + ], + [ + 'name' => 'Shared Variables', + 'type' => 'navigation', + 'description' => 'View all shared variables', + 'link' => route('shared-variables.index'), + 'search_text' => 'shared variables environment all', + ], + [ + 'name' => 'Team Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage team-wide shared variables', + 'link' => route('shared-variables.team.index'), + 'search_text' => 'shared variables team environment', + ], + [ + 'name' => 'Project Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage project shared variables', + 'link' => route('shared-variables.project.index'), + 'search_text' => 'shared variables project environment', + ], + [ + 'name' => 'Environment Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage environment shared variables', + 'link' => route('shared-variables.environment.index'), + 'search_text' => 'shared variables environment', + ], + [ + 'name' => 'Tags', + 'type' => 'navigation', + 'description' => 'View resources by tags', + 'link' => route('tags.show'), + 'search_text' => 'tags labels organize', + ], + $user->can('canAccessTerminal') ? [ + 'name' => 'Terminal', + 'type' => 'navigation', + 'description' => 'Access server terminal', + 'link' => route('terminal'), + 'search_text' => 'terminal ssh console shell command line', + ] : null, + [ + 'name' => 'Profile', + 'type' => 'navigation', + 'description' => 'Manage your profile and preferences', + 'link' => route('profile'), + 'search_text' => 'profile account user settings preferences', + ], + [ + 'name' => 'Team', + 'type' => 'navigation', + 'description' => 'Manage team members and settings', + 'link' => route('team.index'), + 'search_text' => 'team settings members users invitations', + ], + [ + 'name' => 'Notifications', + 'type' => 'navigation', + 'description' => 'Configure email, Discord, Telegram notifications', + 'link' => route('notifications.email'), + 'search_text' => 'notifications alerts email discord telegram slack pushover', + ], + [ + 'name' => 'Analytics', + 'type' => 'navigation', + 'description' => 'View traffic analytics for your servers', + 'link' => route('analytics'), + 'search_text' => 'analytics traffic requests visitors metrics', + ], + $user->isAdmin() ? [ + 'name' => 'Registries', + 'type' => 'navigation', + 'description' => 'Manage Docker registry credentials', + 'link' => route('registries.index'), + 'search_text' => 'registries docker registry credentials login private images', + ] : null, + [ + 'name' => 'Server Shared Variables', + 'type' => 'navigation', + 'description' => 'Manage server shared variables', + 'link' => route('shared-variables.server.index'), + 'search_text' => 'shared variables server environment', + ], + [ + 'name' => 'Team Members', + 'type' => 'navigation', + 'description' => 'Manage team members and invitations', + 'link' => route('team.member.index'), + 'search_text' => 'team members users invitations invite roles', + ], + $user->isAdminOfTeam($team->id) ? [ + 'name' => 'Team Audit Log', + 'type' => 'navigation', + 'description' => 'View team activity history', + 'link' => route('team.audit-log'), + 'search_text' => 'team audit log activity history events', + ] : null, + isInstanceAdmin() ? [ + 'name' => 'Team Admin View', + 'type' => 'navigation', + 'description' => 'Manage all users of this instance', + 'link' => route('team.admin-view'), + 'search_text' => 'team admin view users instance delete', + ] : null, + $user->can('delete', $team) ? [ + 'name' => 'Team Danger Zone', + 'type' => 'navigation', + 'description' => 'Delete this team', + 'link' => route('team.danger-zone'), + 'search_text' => 'team danger zone delete remove', + ] : null, + [ + 'name' => 'API Tokens', + 'type' => 'navigation', + 'description' => 'Manage API tokens', + 'link' => route('security.api-tokens'), + 'search_text' => 'security api tokens personal access token', + ], + $user->can('viewAny', CloudProviderToken::class) ? [ + 'name' => 'Cloud Tokens', + 'type' => 'navigation', + 'description' => 'Manage cloud provider tokens', + 'link' => route('security.cloud-tokens'), + 'search_text' => 'security cloud provider tokens hetzner digitalocean', + ] : null, + $user->can('viewAny', IntegrationToken::class) ? [ + 'name' => 'Integration Tokens', + 'type' => 'navigation', + 'description' => 'Manage integration tokens', + 'link' => route('security.integration-tokens'), + 'search_text' => 'security integration tokens', + ] : null, + [ + 'name' => 'Discord Notifications', + 'type' => 'navigation', + 'description' => 'Configure Discord notifications', + 'link' => route('notifications.discord'), + 'search_text' => 'notifications alerts discord webhook', + ], + [ + 'name' => 'Telegram Notifications', + 'type' => 'navigation', + 'description' => 'Configure Telegram notifications', + 'link' => route('notifications.telegram'), + 'search_text' => 'notifications alerts telegram bot', + ], + [ + 'name' => 'Slack Notifications', + 'type' => 'navigation', + 'description' => 'Configure Slack notifications', + 'link' => route('notifications.slack'), + 'search_text' => 'notifications alerts slack webhook', + ], + [ + 'name' => 'Pushover Notifications', + 'type' => 'navigation', + 'description' => 'Configure Pushover notifications', + 'link' => route('notifications.pushover'), + 'search_text' => 'notifications alerts pushover', + ], + [ + 'name' => 'Webhook Notifications', + 'type' => 'navigation', + 'description' => 'Configure webhook notifications', + 'link' => route('notifications.webhook'), + 'search_text' => 'notifications alerts webhook http', + ], + [ + 'name' => 'Appearance', + 'type' => 'navigation', + 'description' => 'Change theme and display preferences', + 'link' => route('profile.appearance'), + 'search_text' => 'profile appearance theme dark light mode preferences', + ], + isCloud() && $user->isAdmin() ? [ + 'name' => 'Subscription', + 'type' => 'navigation', + 'description' => 'Manage your subscription and billing', + 'link' => route('subscription.show'), + 'search_text' => 'subscription billing plan payment invoices', + ] : null, + ])->filter(); + + // Add instance settings only for self-hosted and root team + if (! isCloud() && $team->id === 0) { + $navigation->push( + [ + 'name' => 'Settings', + 'type' => 'navigation', + 'description' => 'Instance settings and configuration', + 'link' => route('settings.index'), + 'search_text' => 'settings general configuration instance url domain name timezone public ip https redirect', + ], + [ + 'name' => 'Advanced Settings', + 'type' => 'navigation', + 'description' => 'Registration, API access, MCP server, DNS validation and telemetry', + 'link' => route('settings.advanced'), + 'search_text' => 'settings advanced registration api access allowed ips mcp server dns validation custom dns domain connect confirmation telemetry sponsorship cdn', + ], + [ + 'name' => 'Update Settings', + 'type' => 'navigation', + 'description' => 'Configure Coolify auto updates', + 'link' => route('settings.updates'), + 'search_text' => 'settings updates auto update upgrade version frequency', + ], + [ + 'name' => 'Instance Backup', + 'type' => 'navigation', + 'description' => 'Back up the Coolify instance database', + 'link' => route('settings.backup'), + 'search_text' => 'settings instance backup coolify database restore', + ], + [ + 'name' => 'Instance Email', + 'type' => 'navigation', + 'description' => 'Configure SMTP or Resend for system emails', + 'link' => route('settings.email'), + 'search_text' => 'settings instance email smtp resend transactional mail', + ], + [ + 'name' => 'Authentication', + 'type' => 'navigation', + 'description' => 'Configure OAuth login providers', + 'link' => route('settings.oauth'), + 'search_text' => 'settings authentication oauth sso login providers github google gitlab azure', + ], + ); + } + + return $navigation; } private function search() diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 90063c7c28..30d8be3a67 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4450,6 +4450,49 @@ function wireNavigate(): string } } +/** + * Flattens a grouped settings sidebar into the items that the command palette + * shows for the current page: every page, its child pages, and its in-page sections. + * + * @param iterable}>> $groupedItems + * @param array $routeParameters + * @param array> $pageSections In-page sections keyed by page route + * @return array + */ +function settingsSearchItems(iterable $groupedItems, array $routeParameters, array $pageSections = []): array +{ + $spaNavigation = wireNavigate() !== ''; + $items = []; + $add = function (string $label, string $breadcrumb, string $href, bool $navigate) use (&$items, $spaNavigation): void { + $items[] = [ + 'label' => $label, + 'breadcrumb' => $breadcrumb, + 'search_text' => $label.' '.$breadcrumb, + 'href' => $href, + 'navigate' => $navigate && $spaNavigation, + ]; + }; + + foreach ($groupedItems as $groupLabel => $groupItems) { + foreach ($groupItems as $item) { + $href = route($item['route'], $routeParameters); + $add($item['label'], $groupLabel, $href, $item['navigate'] ?? true); + + foreach ($item['children'] ?? [] as $child) { + if ($child['visible'] ?? true) { + $add($child['label'], $groupLabel.' · '.$item['label'], route($child['route'], $routeParameters), $child['navigate'] ?? true); + } + } + + foreach ($pageSections[$item['route']] ?? [] as $section) { + $add($section['label'], $groupLabel.' · '.$item['label'], $href.'#'.$section['id'], true); + } + } + } + + return $items; +} + /** * Redirect to a named route with SPA navigation support. * Automatically uses wire:navigate when is_wire_navigate_enabled is true. diff --git a/resources/css/app.css b/resources/css/app.css index 4f42de4775..407b1d9874 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4509,7 +4509,7 @@ html[data-theme="custom"] .runtime-log-columns { .command-palette { display: flex; width: 100%; - max-width: 36rem; + max-width: 42rem; flex-direction: column; overflow: hidden; border-radius: 10px; @@ -4566,47 +4566,44 @@ html[data-theme="custom"] .runtime-log-columns { border: 0 !important; } -.command-palette-shortcuts { - display: flex; +/* Search scope switch (Global / This page), shown on resource settings pages */ +.command-palette-scope { + display: inline-flex; flex-shrink: 0; align-items: center; - gap: 0.375rem; + gap: 0.125rem; + padding: 0.125rem; + border-radius: 0.5rem; + border: 1px solid var(--coollabs-hairline); } -.command-palette-kbd { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.5rem; +.command-palette-scope-option { height: 1.375rem; - padding: 0 0.375rem; + padding: 0 0.5rem; border-radius: 0.375rem; - border: 1px solid transparent; - background: var(--coollabs-recessed); color: var(--coollabs-subtle); - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 500; - line-height: 1; - font-family: inherit; -} - -.dark .command-palette-kbd { - border-color: rgb(255 255 255 / 0.05); - background: rgb(255 255 255 / 0.06); -} - -button.command-palette-kbd { + white-space: nowrap; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; } -button.command-palette-kbd:hover { +.command-palette-scope-option:hover { + color: #171717; +} + +.command-palette-scope-option[aria-pressed='true'] { color: #171717; background: var(--coollabs-fill); } -.dark button.command-palette-kbd:hover { +.dark .command-palette-scope-option:hover, +.dark .command-palette-scope-option[aria-pressed='true'] { color: var(--color-fg); +} + +.dark .command-palette-scope-option[aria-pressed='true'] { background: rgb(255 255 255 / 0.1); } diff --git a/resources/js/app.js b/resources/js/app.js index 4a4691e06f..46e4a33dd2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -34,10 +34,10 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo * after the scroll has settled. Starting the flash immediately makes long * jumps (top → bottom) finish scrolling after the animation has already ended. * - * @param {string} id + * @param {string|HTMLElement} target Section id or element */ -window.scrollToSettingsSection = function scrollToSettingsSection(id) { - const el = document.getElementById(id); +window.scrollToSettingsSection = function scrollToSettingsSection(target) { + const el = typeof target === 'string' ? document.getElementById(target) : target; if (!el) { return; } @@ -136,6 +136,47 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) { rafId = window.requestAnimationFrame(tick); }; +/** + * Items for the "This page" scope of the command palette: the pages of the + * settings sidebar (rendered by the server), then the visible sections of the + * current page. Sections that the sidebar already lists are skipped. + * + * @returns {Array<{label: string, breadcrumb: string, search_text: string, href: string, navigate?: boolean, element?: HTMLElement}>} + */ +window.currentPageSearchItems = function currentPageSearchItems() { + let items = []; + try { + items = JSON.parse(document.querySelector('[data-settings-search-items]')?.dataset.settingsSearchItems || '[]'); + } catch (e) { + items = []; + } + + const toPath = (href) => { + const url = new URL(href, window.location.href); + return url.pathname + url.hash; + }; + const sidebarPaths = new Set(items.map((item) => toPath(item.href))); + const headingSelector = ':scope > header :is(h1, h2, h3), :scope > .application-settings-section-header :is(h1, h2, h3)'; + + document.querySelectorAll('.application-settings-section').forEach((section, index) => { + if (section.closest('[role=dialog], .command-palette') || section.getClientRects().length === 0) { + return; + } + if (section.id && sidebarPaths.has(window.location.pathname + '#' + section.id)) { + return; + } + const label = (section.dataset.settingsSectionTitle ?? section.querySelector(headingSelector)?.textContent ?? '') + .replace(/\s+/g, ' ') + .trim(); + if (!label) { + return; + } + items.push({ label, breadcrumb: 'Section', search_text: label + ' section', href: 'section-' + index, element: section }); + }); + + return items; +}; + // When a settings sub-section link navigates across pages (href="route#section-id"), // scroll to that section once the destination page has rendered. function scrollToHashSettingsSection() { diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js index b429136d85..11dec0c320 100644 --- a/resources/js/settings-sidebar-accordion.js +++ b/resources/js/settings-sidebar-accordion.js @@ -9,21 +9,6 @@ export function initializeSettingsSidebarAccordionComponent() { activeGroup: config.activeGroup || '', storageKey: config.storageKey || 'coolify.settings-sidebar', groups: {}, - // Optional client-side filter (sidebars that render a search box). - search: '', - labels: Array.isArray(config.labels) ? config.labels : [], - get searching() { - return this.search.trim() !== ''; - }, - matches(label) { - if (!this.searching) { - return true; - } - return String(label).toLowerCase().includes(this.search.trim().toLowerCase()); - }, - get hasResults() { - return !this.searching || this.labels.some((label) => this.matches(label)); - }, init() { let stored = {}; try { diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 844786ce9d..6924f0198d 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -243,81 +243,23 @@ ], ]; - // Flat, searchable index: every page plus its in-page sub-sections. Each - // entry carries a breadcrumb (its category, and parent page for a - // sub-section) and combined text so the query matches sub-pages too. - $searchIndex = []; - foreach ($groupedMenuItems as $groupLabel => $groupItems) { - foreach ($groupItems as $item) { - $searchIndex[] = [ - 'label' => $item['label'], - 'breadcrumb' => $groupLabel, - 'searchText' => $item['label'].' '.$groupLabel, - 'href' => route($item['route'], $applicationRouteParameters), - 'icon' => $menuIcons[$item['label']] ?? 'settings', - 'navigate' => $item['navigate'] ?? true, - ]; - foreach ($pageSections[$item['route']] ?? [] as $section) { - $searchIndex[] = [ - 'label' => $section['label'], - 'breadcrumb' => $groupLabel.' · '.$item['label'], - 'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel, - 'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'], - 'icon' => $menuIcons[$item['label']] ?? 'settings', - 'navigate' => true, - ]; - } - } - } - $searchTexts = array_column($searchIndex, 'searchText'); + // Pages and in-page sections that the command palette (⌘K) can search on this page + $settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections); @endphp