diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 8aeccc37db..2ddf79fd2c 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -4,25 +4,34 @@ @tailwind utilities; @media (min-width: 1760px) { - :root { - font-size: 18px; - } + :root { + font-size: 18px; + } } @layer base { - /* Light mode: default border color */ - .border, .border-t, .border-r, .border-b, .border-l, - .border-x, .border-y, - .divide-x > :not([hidden]) ~ :not([hidden]), + .border, + .border-t, + .border-r, + .border-b, + .border-l, + .border-x, + .border-y, + .divide-x > :not([hidden]) ~ :not([hidden]), .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: rgb(var(--color-border-light)); } /* Dark mode: change border color */ - .dark .border, .dark .border-t, .dark .border-r, .dark .border-b, .dark .border-l, - .dark .border-x, .dark .border-y, - .dark .divide-x > :not([hidden]) ~ :not([hidden]), + .dark .border, + .dark .border-t, + .dark .border-r, + .dark .border-b, + .dark .border-l, + .dark .border-x, + .dark .border-y, + .dark .divide-x > :not([hidden]) ~ :not([hidden]), .dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: rgb(var(--color-border-light)); } @@ -205,11 +214,21 @@ svelte-virtual-list-contents > * + * { /* Prevent clock icon in input[type="time"] making the input taller */ /* Chrome, Safari, Edge, Opera */ -input[type="time"]::-webkit-calendar-picker-indicator { +input[type='time']::-webkit-calendar-picker-indicator { margin: 0; - padding: 0; + padding: 0; +} + +/* Settings search highlight (used by both the drawer and the setup page) */ +[data-setting-key] { + transition: outline 0.8s ease; + outline: 2px solid transparent; + outline-offset: -2px; +} +[data-setting-key].setting-highlight { + outline: 2px solid rgb(var(--color-border-accent, 59 130 246)); } .svelte-flow__edges { z-index: -10; -} \ No newline at end of file +} diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 968aea527c..b493469139 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -38,7 +38,15 @@ warning?: string } - let { setting, version, values, loading = true, openSmtpSettings, oauths, warning }: Props = $props() + let { + setting, + version, + values, + loading = true, + openSmtpSettings, + oauths, + warning + }: Props = $props() const dispatch = createEventDispatcher() let latestKeyRenewalAttempt: { @@ -174,7 +182,12 @@ {#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hiddenInEe && $enterpriseLicense)} {#if setting.fieldType == 'select'} - + {#snippet children({ item: toggleButton })} {#each setting.select_items ?? [] as item} @@ -189,7 +202,12 @@ {:else if setting.fieldType == 'select_python'} - + {#snippet children({ item: toggleButton })} {#each setting.select_items ?? [] as item} @@ -234,6 +252,7 @@ label="Memory" description="Configure the memory budget for the indexer and manage index clearing." ee_only="" + settingKey="indexer_settings_memory" >
@@ -243,6 +262,7 @@ label="Completed Job Index" description="Configure indexing parameters for completed jobs." ee_only="" + settingKey="indexer_settings_jobs" >
@@ -252,6 +272,7 @@ label="Service Logs Index" description="Configure indexing parameters for service logs." ee_only="" + settingKey="indexer_settings_logs" >
@@ -264,6 +285,7 @@ description={setting.description} ee_only={setting.ee_only} tooltip={setting.tooltip} + settingKey={setting.key} actionButton={setting.actionButton} values={$values} > diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index b7cdff2c27..22883567bb 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -26,16 +26,19 @@ import InstanceNameEditor from './InstanceNameEditor.svelte' import Toggle from './Toggle.svelte' import { instanceSettingsSelectedTab } from '$lib/stores' - import { onDestroy } from 'svelte' + import { onDestroy, tick } from 'svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import { instanceSettingsNavigationGroups, tabToCategoryMap, tabToAuthSubTab, - categoryToTabMap + categoryToTabMap, + buildSearchableSettingItems, + type SearchableSettingItem } from './instanceSettings' import TextInput from './text_input/TextInput.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' + import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte' let filter = $state('') @@ -138,6 +141,35 @@ export function syncBeforeDiff(): boolean { return instanceSettings?.syncBeforeDiff() ?? true } + + // --- Settings search --- + const searchableItems = buildSearchableSettingItems() + + let scrollTimeout: ReturnType | undefined + let highlightTimeout: ReturnType | undefined + + async function handleSearchSelect(item: SearchableSettingItem) { + handleNavigate(item.tabId) + if (item.settingKey) { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + await tick() + // Wait for the tab content to render before scrolling + scrollTimeout = setTimeout(() => { + const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('setting-highlight') + highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500) + } + }, 100) + } + } + + onDestroy(() => { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + })
+ {#if $workspaceStore !== 'admins'} -
+ + diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index deda42a994..287d7aa9a2 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -321,7 +321,7 @@ export const settings: Record = { ], SMTP: [ { - label: 'SMTP', + label: 'SMTP configuration', key: 'smtp_settings', fieldType: 'smtp_connect', storage: 'setting', @@ -782,3 +782,84 @@ export const categoryToTabMap: Record = { Jobs: 'jobs', 'Private Hub': 'private_hub' } + +export interface SearchableSettingItem { + label: string + tabId: string + settingKey?: string + category: string + /** Full description text (HTML stripped), used for search matching only — not displayed */ + description?: string +} + +/** + * Extract the label portion from a uFuzzy marked/highlighted string. + * Only allows `` and `` tags through (sanitizes everything else). + */ +export function extractMarkedLabel(marked: string | undefined, labelLength: number): string { + if (!marked) return '' + let plainIdx = 0 + let markedIdx = 0 + while (plainIdx < labelLength && markedIdx < marked.length) { + if (marked[markedIdx] === '<') { + while (markedIdx < marked.length && marked[markedIdx] !== '>') markedIdx++ + markedIdx++ + } else { + plainIdx++ + markedIdx++ + } + } + // Include any closing right after + if (marked.startsWith('', markedIdx)) { + markedIdx += ''.length + } + // Sanitize: only allow and tags from uFuzzy highlight + return marked.slice(0, markedIdx).replace(/<(?!\/?mark>)[^>]*>/g, '') +} + +export function buildSearchableSettingItems( + navigationGroups: typeof instanceSettingsNavigationGroups = instanceSettingsNavigationGroups +): SearchableSettingItem[] { + const items: SearchableSettingItem[] = [] + + // Add sidebar navigation items (tab-level) + for (const group of navigationGroups) { + for (const navItem of group.items) { + items.push({ + label: navItem.label, + tabId: navItem.id, + category: group.title + }) + } + } + + // Add individual settings from each category + for (const [category, categorySettings] of Object.entries(settings)) { + const tabId = categoryToTabMap[category] + if (!tabId) continue + for (const setting of categorySettings) { + if (!setting.label) continue + items.push({ + label: setting.label, + tabId, + settingKey: setting.key, + category, + description: setting.description?.replace(/<[^>]*>/g, '') ?? '' + }) + } + } + + // Add SCIM/SAML settings + for (const setting of scimSamlSetting) { + if (!setting.label) continue + items.push({ + label: setting.label, + tabId: 'scim_saml', + settingKey: setting.key, + category: 'SCIM/SAML', + description: setting.description?.replace(/<[^>]*>/g, '') ?? '' + }) + } + + return items +} diff --git a/frontend/src/lib/components/instanceSettings/SettingCard.svelte b/frontend/src/lib/components/instanceSettings/SettingCard.svelte index b0ecc3b557..892a3c0d62 100644 --- a/frontend/src/lib/components/instanceSettings/SettingCard.svelte +++ b/frontend/src/lib/components/instanceSettings/SettingCard.svelte @@ -11,6 +11,7 @@ description?: string ee_only?: string tooltip?: string + settingKey?: string actionButton?: { label: string onclick: (values: Record) => Promise @@ -26,6 +27,7 @@ description, ee_only, tooltip, + settingKey, actionButton, values, children, @@ -33,7 +35,10 @@ }: Props = $props() -
+
{#if label}
diff --git a/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte b/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte new file mode 100644 index 0000000000..c9c94a1393 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte @@ -0,0 +1,89 @@ + + + x.label + ' ' + (x.description ?? '') + ' ' + x.category} +/> + +
+
+ + +
+ searchInputEl!.getBoundingClientRect() : undefined} + onSelectValue={(item) => handleSelect(item.value)} + highlightFirstOnOpen + maxHeight={400} + > + {#snippet startSnippet({ item })} +
{@html extractMarkedLabel(item.value.marked, item.value.label.length)}
+ {/snippet} +
+
diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index 13b87324cd..08ebadd909 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -21,12 +21,14 @@ ulClass = '', itemLabelWrapperClasses = '', itemButtonWrapperClasses = '', + maxHeight = 256, header, getInputRect, onSelectValue, startSnippet, endSnippet, - bottomSnippet + bottomSnippet, + highlightFirstOnOpen = false }: { processedItems?: ProcessedItem[] value: T | undefined @@ -40,12 +42,15 @@ ulClass?: string itemLabelWrapperClasses?: string itemButtonWrapperClasses?: string + maxHeight?: number header?: Snippet getInputRect?: () => DOMRect onSelectValue: (item: ProcessedItem) => void startSnippet?: Snippet<[{ item: ProcessedItem; close: () => void }]> endSnippet?: Snippet<[{ item: ProcessedItem; close: () => void }]> bottomSnippet?: Snippet<[{ close: () => void }]> + /** When true, the first item is highlighted when the dropdown opens (even without filterText) */ + highlightFirstOnOpen?: boolean } = $props() let processedItems = $derived( @@ -92,7 +97,7 @@ $effect(() => { ;[open, processedItems] - untrack(() => (keyArrowPos = open && filterText ? 0 : undefined)) + untrack(() => (keyArrowPos = open && (filterText || highlightFirstOnOpen) ? 0 : undefined)) }) // We do not want to render the dropdown when it is closed for performance reasons @@ -185,7 +190,11 @@ )} style="height: {uiState.visible ? dropdownPos.height : 0}px;" > -
+
{@render header?.()} {#if processedItems?.length === 0}
{noItemsMsg}
diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 0f474b47ec..772480d0de 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -10,11 +10,15 @@ setupNavigationGroups, tabToCategoryMap, tabToAuthSubTab, - categoryToTabMap + categoryToTabMap, + buildSearchableSettingItems, + type SearchableSettingItem } from '$lib/components/instanceSettings' + import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte' import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte' import { ChevronRight, ArrowLeft } from 'lucide-svelte' import { superadmin } from '$lib/stores' + import { onDestroy, tick } from 'svelte' import { UserService, JobService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -30,7 +34,10 @@ const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard' - const initialStep = Math.max(0, Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1)) + const initialStep = Math.max( + 0, + Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1) + ) let mode: 'wizard' | 'full' = $state(initialMode) let wizardStep = $state(initialStep) @@ -106,7 +113,12 @@ hubSyncStatus = 'success' hubSyncMessage = 'Resource types synced from hub successfully' } catch (e: any) { - hubSyncMessage = e?.body?.error?.message || e?.body?.message || (typeof e?.body === 'string' ? e.body : null) || e?.message || 'Failed to sync from hub' + hubSyncMessage = + e?.body?.error?.message || + e?.body?.message || + (typeof e?.body === 'string' ? e.body : null) || + e?.message || + 'Failed to sync from hub' hubSyncStatus = 'error' } } @@ -151,7 +163,33 @@ } } + // --- Settings search (full mode) --- + const searchableItems = buildSearchableSettingItems(setupNavigationGroups) + let scrollTimeout: ReturnType | undefined + let highlightTimeout: ReturnType | undefined + + async function handleSearchSelect(item: SearchableSettingItem) { + handleNavigate(item.tabId) + if (item.settingKey) { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + await tick() + scrollTimeout = setTimeout(() => { + const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('setting-highlight') + highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500) + } + }, 100) + } + } + + onDestroy(() => { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + }) /** Check if we need to warn about missing EE license key before proceeding */ function proceedFromCore(callback: () => void) { @@ -312,9 +350,7 @@ {/key} {:else} - +
- +
-
+
{#if !yamlMode}
+ Quick setup - + {/if}