Repository: amir20/dozzle (language: go)

Issue:
Scrollbar for search menu
Hello!

Is it possible to expand or add a scrollbar for the search menu?
When you entering search menu and type some words, it shows you only 5 matches. It's not very comfortable when u want to search or just see some services in your huge docker stack, for example. Or to see all containers with the same name match at once. (if there is more then 5 copies deployed)
This would be a very useful feature i think. At least in our company everyone would like this))

Thanks!


Discussion / hints:
Hello! @Shmatthew could you help me understand your use case? I only show top 5 because I'd hope that if someone searches for something specific then the sorting algorithm is good enough to be in the top 5. What are you searching and how many containers do you have? Screenshots would help. 
I'm sorry but I can't show screenshots for some reasons)
We have docker swarm cluster with 5 nodes with many docker stacks. Some of these stacks contain about 40 services for example. And some of swarm nodes have more than 250 containers running. Some services have more than 6 words in their names and may differ only in the last word for example. And its a little bit hard to search them by your eyes in the left menu list, and also swapping between other nodes to see wich of them deployed the required container.

For ex.
We have containers:
my-stack-name_abc-cba-111
my-stack-name_abc-cba-222
my-stack-name_abc-cba-333
...
my-stack-name_abc-cba-777

In search menu i remember that containers that i need have that part of their name: "my-stack-name_abc-cba", and i dont remember what is in their last word. The search menu will only show me 5 matches, not all these 7 containers. And if these names not a containers' but services' names wich have multiple replication, in that case we have much more than 7 containers with slightly similar names in different masters, that can't be searched in the search menu. Only if i type their full container or service name wich is not easy to remember sometime)
I see. That actually makes sense. I think it does make sense to expand more than 5 given your use case and I agree that when names are similar it is confusing. 

I am hoping to not add a scrollbar but let me play with it today to see what it looks like on mobile. 
Seems to be ok with 10. So I am going to merge. Try `amir20/dozzle-master` 
Thank you very much! I've tried it already. Top 10 is better of course. But it still not enough for our environment in some searches. And when I saw this list of 10 our services, I started scrolling for other matches intuitively)) Btw top10 is ok in many cases i think.
So, if it's hard to do to show all the matches in this "top10" window with a scrolbar and it will degrade performance or not be usable on moblies, then we can close this task, no problem. Top5 is 100% not comfortable, top10 is much better.
Hmmm ok I think I see what you are saying now. Your idea is to actually not limit any results and just show them. Is that right? I use https://www.npmjs.com/package/fuse.js and am not sure if it would break. Ideally, fuse.js would be smart enough to have a threshold and only show matches that are within the threshold. 

What's a good test for myself? I am guessing I should create 50+ containers with similar name like `test-foo-bar-XXX` and randomize the XXX.  Is that similar to your use case? 

I like this approach personally. Assuming fuse.js works then it might be a better experience. 

Fix patch:
diff --git a/assets/components/FuzzySearchModal.spec.ts b/assets/components/FuzzySearchModal.spec.ts
index 388a123c0470..441ff4eb4eb8 100644
--- a/assets/components/FuzzySearchModal.spec.ts
+++ b/assets/components/FuzzySearchModal.spec.ts
@@ -57,9 +57,9 @@ describe("<FuzzySearchModal />", () => {
     vi.mocked(useRouter().push).mockReset();
   });
 
-  test("shows running all", async () => {
+  test("shows none initially", async () => {
     const wrapper = createFuzzySearchModal();
-    expect(wrapper.findAll("li").length).toBe(3);
+    expect(wrapper.findAll("li").length).toBe(0);
   });
 
   test("search for foo", async () => {
diff --git a/assets/components/FuzzySearchModal.vue b/assets/components/FuzzySearchModal.vue
index 50eb877e53bd..62b99c8e544c 100644
--- a/assets/components/FuzzySearchModal.vue
+++ b/assets/components/FuzzySearchModal.vue
@@ -15,44 +15,49 @@
       />
       <mdi:keyboard-esc class="flex" />
     </div>
-    <ul tabindex="0" class="menu dropdown-content !relative mt-2 w-full rounded-box bg-base-lighter p-2">
-      <li v-for="(result, index) in data">
-        <a
-          class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
-          @click.prevent="selected(result.item)"
-          @mouseenter="selectedIndex = index"
-          :class="index === selectedIndex ? 'focus' : ''"
-        >
-          <div :class="{ 'text-primary': result.item.state === 'running' }">
-            <template v-if="result.item.type === 'container'">
-              <octicon:container-24 />
-            </template>
-            <template v-else-if="result.item.type === 'service'">
-              <ph:stack-simple />
-            </template>
-            <template v-else-if="result.item.type === 'stack'">
-              <ph:stack />
-            </template>
-          </div>
-          <div class="truncate">
-            <template v-if="config.hosts.length > 1 && result.item.host">
-              <span class="font-light">{{ result.item.host }}</span> /
-            </template>
-            <span data-name v-html="matchedName(result)"></span>
-          </div>
-
-          <DistanceTime :date="result.item.created" class="text-xs font-light" />
+    <div
+      class="dropdown-content !relative mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md bg-base-lighter p-2"
+      v-if="results.length"
+    >
+      <ul tabindex="0" class="menu">
+        <li v-for="(result, index) in data" ref="listItems">
           <a
-            @click.stop.prevent="addColumn(result.item)"
-            :title="$t('tooltip.pin-column')"
-            class="hover:text-secondary"
+            class="grid auto-cols-max grid-cols-[min-content,auto] gap-2 py-4"
+            @click.prevent="selected(result.item)"
+            @mouseenter="selectedIndex = index"
+            :class="index === selectedIndex ? 'focus' : ''"
           >
-            <ic:sharp-keyboard-return v-if="index === selectedIndex" />
-            <cil:columns v-else />
+            <div :class="{ 'text-primary': result.item.state === 'running' }">
+              <template v-if="result.item.type === 'container'">
+                <octicon:container-24 />
+              </template>
+              <template v-else-if="result.item.type === 'service'">
+                <ph:stack-simple />
+              </template>
+              <template v-else-if="result.item.type === 'stack'">
+                <ph:stack />
+              </template>
+            </div>
+            <div class="truncate">
+              <template v-if="config.hosts.length > 1 && result.item.host">
+                <span class="font-light">{{ result.item.host }}</span> /
+              </template>
+              <span data-name v-html="matchedName(result)"></span>
+            </div>
+
+            <DistanceTime :date="result.item.created" class="text-xs font-light" />
+            <a
+              @click.stop.prevent="addColumn(result.item)"
+              :title="$t('tooltip.pin-column')"
+              class="hover:text-secondary"
+            >
+              <ic:sharp-keyboard-return v-if="index === selectedIndex" />
+              <cil:columns v-else />
+            </a>
           </a>
-        </a>
-      </li>
-    </ul>
+        </li>
+      </ul>
+    </div>
   </div>
 </template>
 
@@ -61,14 +66,11 @@ import { ContainerState } from "@/types/Container";
 import { useFuse } from "@vueuse/integrations/useFuse";
 import { type FuseResult } from "fuse.js";
 
-const { maxResults = 10 } = defineProps<{
-  maxResults?: number;
-}>();
-
 const close = defineEmit();
 
 const query = ref("");
 const input = ref<HTMLInputElement>();
+const listItems = ref<HTMLInputElement[]>();
 const selectedIndex = ref(0);
 
 const router = useRouter();
@@ -133,26 +135,22 @@ const { results } = useFuse(query, list, {
     threshold: 0.3,
     includeMatches: true,
   },
-  resultLimit: 10,
-  matchAllWhenSearchEmpty: true,
 });
 
 const data = computed(() => {
-  return [...results.value]
-    .sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
-      if (a.score === b.score) {
-        if (a.item.state === b.item.state) {
-          return b.item.created.getTime() - a.item.created.getTime();
-        } else if (a.item.state === "running" && b.item.state !== "running") {
-          return -1;
-        } else {
-          return 1;
-        }
+  return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
+    if (a.score === b.score) {
+      if (a.item.state === b.item.state) {
+        return b.item.created.getTime() - a.item.created.getTime();
+      } else if (a.item.state === "running" && b.item.state !== "running") {
+        return -1;
       } else {
-        return (a.score ?? 0) - (b.score ?? 0);
+        return 1;
       }
-    })
-    .slice(0, maxResults);
+    } else {
+      return (a.score ?? 0) - (b.score ?? 0);
+    }
+  });
 });
 
 watch(query, (data) => {
@@ -161,6 +159,8 @@ watch(query, (data) => {
   }
 });
 
+watch(selectedIndex, () => listItems.value?.[selectedIndex.value].scrollIntoView({ behavior: "smooth", block: "end" }));
+
 useFocus(input, { initialValue: true });
 
 function selected(item: Item) {
@@ -202,8 +202,4 @@ function matchedName({ item, matches = [] }: FuseResult<Item>) {
 :deep(mark) {
   @apply bg-transparent text-inherit underline underline-offset-2;
 }
-
-.menu a {
-  @apply transition-none duration-0;
-}
 </style>


Test patch: