Log Viewer

Filter and search a large lazy log stream
Filters and searches 200,000 generated log lines without materializing them: every row is derived from its index on demand, and filtering builds only the array of matching indices. Any filter change rebuilds the index and jumps straight back to the first match.

How to build a feature like this

A log or dataset too large to materialize - or whose lines are a pure function of an index - does not need an array of stored objects. With an index-only model every visible row derives its text on demand, so memory stays flat across 200,000+ lines and rendering costs only what is on screen. Filtering and searching then reduce to building a small array of matching indices and handing it to the same VirtualScroll, which draws only those rows in view. Uniform fixed heights keep layout O(1) and make any jump land precisely. The tradeoff: derived text must be deterministic and inexpensive to compute, because rows are re-derived on every scroll - so this suits generated logs, time series, and lookup-backed tables, not heavy or non-deterministic content.

1. Model rows as a function of their index

If you already hold the logs as an in-memory list, pass that array and read each row's fields from the #item slot's item; add a numeric item-size when rows share one height so layout is O(1) with no DOM measurement. Choose the index-only model when the dataset is very large or each line derives deterministically from its position: items is a sparse placeholder (new Array(count)), the slot renders from index, and content (timestamp, level, message) is computed on demand. Uniform rows keep the horizontal and vertical geometry stable, so a monospace, fixed-height line is the natural row shape.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. The overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { computed, ref } from 'vue';

const log = ref<InstanceType<typeof VirtualScroll> | null>(null);

const TOTAL = 200_000;

// Index-only model: a row's text is a pure function of its index, so the
// dataset is a sparse placeholder and every visible row derives its content on
// demand. A numeric item-size keeps the layout O(1). (If your logs are a
// bounded in-memory list instead, pass that array and read item.level /
// item.message from the #item slot - same props.)
const base = new Array(TOTAL);
const levelFilter = ref<string[]>([]);

// All matching GLOBAL indices - never the derived log objects themselves.
const filtered = ref<number[] | null>(null);
const items = computed(() => filtered.value ?? base);

// A filtered slot holds a number = global log index; an undefined hole (no
// filter active) falls back to the slot index itself.
function globalOf(item: unknown, index: number) {
  return typeof item === 'number' ? item : index;
}
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    ref="log"
    class="log-view"
    :items="items"
    :item-size="40"
  >
    <template #item="{ item, index }">
      <div class="line">{{ globalOf(item, index) }} - {{ textOf(globalOf(item, index)) }}</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.log-view {
  height: 480px;
}
.line {
  box-sizing: border-box;
  height: 40px; /* must equal item-size */
  display: flex;
  align-items: center;
  padding-inline: 12px;
  font-family: ui-monospace, monospace;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
</style>

2. Filter and search without materializing rows

Never touch the source rows when filtering an index-only list - you cannot (they do not exist as objects). Instead scan the dataset, test each candidate index against the active filter, and collect the matching global indices into an array that becomes the new items. Each slot then holds a number (the real log index) instead of a hole, so the row must map it back before deriving content - the snippet's globalOf(item, index) returns the stored number when present and falls back to the slot index when filtering is off. This works because row height is uniform: the filtered array is the same fixed-size rows, only shorter.

Debounce free-text input before rebuilding: testing every candidate derivation on each keystroke is real work even at one index per line. A short delay (a few hundred ms) waits for the user to pause, then the scan runs once and virtualization mounts only the matches in view.

import { ref, watch } from 'vue';

const query = ref('');
const debounced = ref('');

// Debounce free-text search: scanning every candidate derivation per keystroke
// is real work, so wait until the user pauses before rebuilding the indices.
let timer: ReturnType<typeof setTimeout> | undefined;
watch(query, () => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    debounced.value = query.value.trim().toLowerCase();
  }, 250);
});

// Any filter change is a different coordinate space (row 0 = the first match),
// so a stale scroll offset is meaningless - rebuild the index array and jump to
// the top of the new result set.
watch([levelFilter, debounced], () => {
  filtered.value = collectMatchingIndices(); // scan source, gather global indices
  log.value?.scrollToIndex(0, null, { align: 'start', behavior: 'auto' });
});

3. Jump by index after a filter change

A filter change renumbers the space: row 0 is now the first match, so any previously held scroll offset is meaningless. After rebuilding the index array, reset to the top with scrollToIndex(0, null, { align: 'start', behavior: 'auto' }) so the user lands on the first result deterministically. The same API moves you anywhere by index - read the first visible row from ScrollDetails.currentIndex to step one match at a time, or jump to any row of the filtered set directly; because sizes are uniform, the target is computed exactly with no measurement round-trip.

Showing 200,000 / 200,000 lines · top line 0
00000008:00:00.000DEBUGOrderService: cache hit for job #1000 (0 entries)
00000108:00:00.100ERRORPaymentGateway: failed request #1001: timeout
00000208:00:00.200INFOAuthService: completed query #1002 in 7ms
00000308:00:00.300WARNRateLimiter: slow render #1003 - took 303ms
00000408:00:00.400INFORateLimiter: completed batch #1004 in 9ms
00000508:00:00.500WARNSyncEngine: slow upload #1005 - took 305ms
00000608:00:00.600DEBUGAuthService: cache hit for session #1006 (78 entries)
00000708:00:00.700INFOSearchIndex: completed sync #1007 in 12ms
00000808:00:00.800INFOSyncEngine: completed upload #1008 in 13ms
00000908:00:00.900INFOQueueConsumer: completed export #1009 in 14ms
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y