# LogViewer

> Virtualised log stream with level filters and counts, time ranges, timestamps, search with highlights and regex, follow tail, pause and resume, load older, server-side filtering, detail panel, copy and download.

Source: https://docs.nasaqui.com/components/log-viewer

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/log-viewer.json
```

A log stream that behaves like a dev tool. Entries are `{ id, time, level, message, source?, fields? }`, oldest
first. The toolbar has level chips with live counts, a search box with a regex toggle, a timestamps toggle, copy
and download. Rows are fixed-height and windowed, so tens of thousands of entries scroll smoothly. While
following, the list stays on the newest entry; scrolling up stops it and shows "Jump to latest". Select a row to
see the full message and its structured fields. Presentational: append to `entries` to stream.

## When to use

- Application, server or container logs with levels and timestamps.
- Anything a developer would search and filter while debugging.

## When not to use

- Raw command output with colours: use [Terminal](https://docs.nasaqui.com/components/terminal).
- Tabular business data: use [DataTable](https://docs.nasaqui.com/components/data-table).
- Audit trails for people: use a timeline or activity list.

## Import

```tsx
import { LogViewer } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { LogViewer } from "@fadymondy/nasaq/web";

export function Logs() {
  return (
    <LogViewer
      streaming
      entries={[
        { id: 1, time: Date.now(), level: "info", source: "api", message: "listening on :3000" },
        { id: 2, time: Date.now(), level: "error", source: "db", message: "connection refused", fields: { host: "db-1" } },
      ]}
    />
  );
}
```

## Anatomy

```
LogViewer       data-slot="log-viewer"          <div dir="ltr">
├─ toolbar      data-slot="log-viewer-toolbar"  level chips, search, regex, timestamps, copy, download
├─ list         data-slot="log-viewer-list"     <div role="log"> windowed rows, follows the tail
│  └─ row       data-slot="log-viewer-row"      time, level badge, source, message (matches highlighted)
├─ jump         "Jump to latest"                shown when not following
└─ detail       data-slot="log-viewer-detail"   full message and fields of the selected row
```

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `entries` | `LogEntry[]` | required | Oldest first. `id` must be unique. `level`: `trace debug info warn error fatal`. |
| `title?` | `string` | "Logs" / "السجلات" | Header text. |
| `streaming?` | `boolean` | `false` | Live indicator. |
| `follow?` | `boolean` | `true` | Start pinned to the newest entry. |
| `defaultLevels?` | `LogLevel[]` | all | Levels on at the start. |
| `defaultQuery?` | `string` | `""` | Initial search. |
| `timestamps?` | `boolean` | `true` | Show times. The toolbar toggles it. |
| `utc?` | `boolean` | `false` | UTC instead of local time. |
| `rowHeight?` | `number` | `24` | Pixels. Rows are single-line. |
| `height?` | `string \| number` | `"24rem"` | Height of the list. |
| `toolbar?` | `ReactNode` | none | Extra controls, e.g. a source select. |
| `onDownload?` | `(entries) => void` | saves a `.log` file | Receives the visible entries. |
| `downloadFilename?` | `string` | `"logs.log"` | Default download name. |
| `ranges?` | `LogRange[]` | none | Adds a time-range select, e.g. `LOG_RANGES` (15 minutes to all time). |
| `defaultRange?` | `string` | last range | Range id chosen at the start. |
| `manual?` | `boolean` | `false` | The server filters: `entries` are shown as given (matches still highlighted). |
| `onFilterChange?` | `(filter: LogViewerFilter) => void` | none | `{ levels, query, regex, range, since }` after each change. Not called on mount. |
| `counts?` | `Partial<Record<LogLevel, number>>` | counted | Per-level totals for the chips, e.g. from the server. |
| `total?` | `number` | `entries.length` | Total matches, for the footer. |
| `hasOlder?` | `boolean` | `false` | Shows "Load older entries" above the first row. |
| `onLoadOlder?` | `() => void \| Promise<void>` | none | Prepend older entries; the list keeps its place. |
| `loadingOlder?` | `boolean` | tracks the promise | Shows a spinner in place of the button. |
| `liveTail?` | `boolean` | `false` | Adds pause / resume. Paused, new entries wait and are counted. |
| `onLiveChange?` | `(live: boolean) => void` | none | E.g. close the socket while paused. |
| `labels?` | `Partial<LogViewerLabels>` | built-in en/ar | Translations. |

Helpers: `filterLogs`, `compileMatcher`, `countByLevel`, `formatLogTime`, `normalizeLevel` (maps `WARNING`, `err`,
`critical` onto the six levels), `logsToText`, `virtualWindow`, `rangeSince` (a range's lower bound in epoch ms,
`null` for all time) and `entriesUntil` (what a list paused at an id shows). `filterLogs` also takes `since`. All
pure.

## Examples

### Parse and normalise your own lines

```tsx
const entries = raw.map((l, i) => ({ id: i, time: l.ts, level: normalizeLevel(l.severity) ?? "info", message: l.text }));
```

### Server-side filtering, time range and older pages

```tsx
<LogViewer
  manual
  entries={page.entries}
  counts={page.counts}
  total={page.total}
  ranges={LOG_RANGES}
  defaultRange="1h"
  onFilterChange={(f) => refetch({ levels: f.levels, q: f.query, since: f.since })}
  hasOlder={page.hasOlder}
  onLoadOlder={() => fetchOlder(page.entries[0]?.id)}
/>
```

### Live tail with pause

```tsx
<LogViewer entries={entries} streaming liveTail onLiveChange={(live) => (live ? socket.resume() : socket.pause())} />
```

While paused the list holds still, the footer reads "Paused · 12 new entries" and a button resumes and jumps to
the newest entry.

### Extra control

```tsx
<LogViewer entries={entries} toolbar={<Select items={services} value={service} onValueChange={setService} />} />
```

## Accessibility

| Key | Action |
| --- | --- |
| `Tab` | Toolbar, then the list |
| `Up` / `Down` | Move the selected row |
| `Home` / `End` | First row / last row and resume following |
| `PageUp` / `PageDown` | Move by a page |
| `Enter` | Open the detail panel |
| `Esc` | Close the detail panel |

- The list is `role="log"`; the selected row is exposed with `aria-activedescendant`.
- Level chips are toggle buttons with counts in their names. Level is always text, not colour alone.
- An invalid regex shows an inline message and does not clear the list.

## RTL & i18n

- The surface is `dir="ltr"`: timestamps, sources and messages are code. The toolbar labels are translated.
- Timestamps use Latin digits and a fixed width so columns line up in any locale.
- Built-in Arabic strings; override with `labels`.

## Styling & tokens

- Level colours: `text-nq-danger-text`, `-warning-text`, `-info-text`, `-success-text`, `text-muted-foreground`. Selection `bg-nq-selected`, hover `bg-nq-hover`, matches `bg-nq-warning-soft`.
- Slots: `log-viewer`, `log-viewer-toolbar`, `log-viewer-list`, `log-viewer-row`, `log-viewer-detail`.

## Do / Don't

- **Do** give every entry a stable `id`.
- **Do** cap the number of entries you keep in memory for very long sessions.
- **Don't** log secrets; the viewer shows exactly what you pass.
- **Don't** rely on wrapped rows: rows are one line, and the detail panel shows the full text.

## Related

- [Terminal](https://docs.nasaqui.com/components/terminal) · [DeployView](https://docs.nasaqui.com/components/deploy-view) · [DataTable](https://docs.nasaqui.com/components/data-table)

## Lab

https://docs.nasaqui.com/?path=/docs/components-monitoring-log-viewer--docs

## Code

### React

```tsx
import { LogViewer } from "@fadymondy/nasaq/web";

export function Logs() {
  return (
    <LogViewer
      streaming
      entries={[
        { id: 1, time: Date.now(), level: "info", source: "api", message: "listening on :3000" },
        { id: 2, time: Date.now(), level: "error", source: "db", message: "connection refused", fields: { host: "db-1" } },
      ]}
    />
  );
}
```

### shadcn

```tsx
import { LogViewer } from "@/components/ui/log-viewer";

export function Logs() {
  return (
    <LogViewer
      streaming
      entries={[
        { id: 1, time: Date.now(), level: "info", source: "api", message: "listening on :3000" },
        { id: 2, time: Date.now(), level: "error", source: "db", message: "connection refused", fields: { host: "db-1" } },
      ]}
    />
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqLogViewer, type LogEntry } from "@fadymondy/nasaq/vue";

const now = Date.parse("2026-09-29T09:00:00Z");
const entries: LogEntry[] = [
  { id: 1, time: now, level: "info", source: "api", message: "listening on :3000" },
  { id: 2, time: now + 1200, level: "error", source: "db", message: "connection refused", fields: { host: "db-1" } },
];
</script>

<template>
  <NqLogViewer streaming :entries="entries" />
</template>
```

### Blade

```blade
@php
    $now = strtotime('2026-09-29T09:00:00Z') * 1000;
    $entries = [
        ['id' => 1, 'time' => $now, 'level' => 'info', 'source' => 'api', 'message' => 'listening on :3000'],
        ['id' => 2, 'time' => $now + 1200, 'level' => 'error', 'source' => 'db', 'message' => 'connection refused', 'fields' => ['host' => 'db-1']],
    ];
@endphp
<x-nq::log-viewer streaming :entries="$entries" :ranges="true" has-older live-tail />
```

### HTML + Alpine

```html
<div data-slot="log-viewer" dir="ltr" x-data="nqLogViewer(JSON.parse('{\u0022entries\u0022:[{\u0022id\u0022:1,\u0022time\u0022:1790672400000,\u0022level\u0022:\u0022info\u0022,\u0022source\u0022:\u0022api\u0022,\u0022message\u0022:\u0022listening on :3000\u0022},{\u0022id\u0022:2,\u0022time\u0022:1790672401200,\u0022level\u0022:\u0022error\u0022,\u0022source\u0022:\u0022db\u0022,\u0022message\u0022:\u0022connection refused\u0022,\u0022fields\u0022:{\u0022host\u0022:\u0022db-1\u0022}}],\u0022streaming\u0022:true,\u0022follow\u0022:true,\u0022defaultLevels\u0022:null,\u0022defaultQuery\u0022:\u0022\u0022,\u0022timestamps\u0022:true,\u0022utc\u0022:false,\u0022rowHeight\u0022:24,\u0022downloadFilename\u0022:\u0022logs.log\u0022,\u0022strings\u0022:{\u0022copied\u0022:\u0022Logs copied to clipboard\u0022,\u0022entryCopied\u0022:\u0022Entry copied to clipboard\u0022,\u0022countAll\u0022:\u0022{total} entries\u0022,\u0022countSome\u0022:\u0022{shown} of {total} entries\u0022,\u0022emptyTitle\u0022:\u0022No logs yet\u0022,\u0022emptyBody\u0022:\u0022New entries show up here as they arrive.\u0022,\u0022noMatchTitle\u0022:\u0022No entries match\u0022,\u0022noMatchBody\u0022:\u0022Try a different search or turn on more levels.\u0022,\u0022pause\u0022:\u0022Pause live tail\u0022,\u0022resume\u0022:\u0022Resume live tail\u0022,\u0022newOne\u0022:\u00221 new entry\u0022,\u0022newMany\u0022:\u0022{n} new entries\u0022},\u0022manual\u0022:false,\u0022counts\u0022:null,\u0022total\u0022:null,\u0022ranges\u0022:[{\u0022id\u0022:\u002215m\u0022,\u0022label\u0022:\u0022Last 15 minutes\u0022,\u0022labelAr\u0022:\u0022\\u0622\\u062e\\u0631 \\u0661\\u0665 \\u062f\\u0642\\u064a\\u0642\\u0629\u0022,\u0022ms\u0022:900000},{\u0022id\u0022:\u00221h\u0022,\u0022label\u0022:\u0022Last hour\u0022,\u0022labelAr\u0022:\u0022\\u0622\\u062e\\u0631 \\u0633\\u0627\\u0639\\u0629\u0022,\u0022ms\u0022:3600000},{\u0022id\u0022:\u002224h\u0022,\u0022label\u0022:\u0022Last 24 hours\u0022,\u0022labelAr\u0022:\u0022\\u0622\\u062e\\u0631 \\u0662\\u0664 \\u0633\\u0627\\u0639\\u0629\u0022,\u0022ms\u0022:86400000},{\u0022id\u0022:\u00227d\u0022,\u0022label\u0022:\u0022Last 7 days\u0022,\u0022labelAr\u0022:\u0022\\u0622\\u062e\\u0631 \\u0667 \\u0623\\u064a\\u0627\\u0645\u0022,\u0022ms\u0022:604800000},{\u0022id\u0022:\u0022all\u0022,\u0022label\u0022:\u0022All time\u0022,\u0022labelAr\u0022:\u0022\\u0643\\u0644 \\u0627\\u0644\\u0623\\u0648\\u0642\\u0627\\u062a\u0022,\u0022ms\u0022:null}],\u0022defaultRange\u0022:\u0022all\u0022,\u0022hasOlder\u0022:true,\u0022liveTail\u0022:true}'))" x-bind:data-streaming="streaming ? '' : null" x-bind:data-paused="paused ? '' : null"
    class="relative flex min-w-0 flex-col overflow-hidden rounded-surface border border-border bg-nq-surface-soft text-start">
    <div data-slot="log-viewer-toolbar" class="flex flex-wrap items-center gap-2 border-b border-border p-2">
        <div role="group" data-slot="input-group"
    class="group/input-group flex h-control min-h-[var(--nq-touch-min,0px)] w-full items-center overflow-hidden rounded-control border border-input bg-card text-body text-foreground transition-colors duration-150 ease-nq focus-within:border-nq-focus focus-within:outline-1 focus-within:outline-nq-focus has-[[data-invalid]]:border-nq-danger has-[[aria-invalid=true]]:border-nq-danger has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 min-w-40 flex-1 basis-56"><div data-slot="input-group-addon" data-align="start"
    class="flex h-full shrink-0 items-center gap-1.5 text-body-sm text-muted-foreground [&_svg]:size-4 order-first ps-3 pe-1"><svg data-slot="icon" aria-hidden="true" class="size-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m21 21-4.34-4.34"/>
  <circle cx="11" cy="11" r="8"/>
</svg></div>
            <input data-slot="input-group-input"  dir="ltr"     type="search" x-model="query" placeholder="Search logs" aria-label="Search logs" x-bind:aria-invalid="invalid ? &#039;true&#039; : null" x-bind:aria-describedby="invalid ? uid + &#039;-invalid&#039; : null" class="h-full min-w-0 flex-1 border-0 bg-transparent px-3 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed pointer-coarse:text-[16px] text-start font-mono text-code" />
            <div data-slot="input-group-addon" data-align="end"
    class="flex h-full shrink-0 items-center gap-1.5 text-body-sm text-muted-foreground [&_svg]:size-4 order-last ps-1 pe-3"><button data-slot="button"
     type="button"                         aria-label="Use regular expression" x-on:click="regex = ! regex" x-bind:aria-pressed="regex ? &#039;true&#039; : &#039;false&#039;" x-bind:data-active="regex ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 data-active:bg-nq-selected">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M17 3v10"/>
  <path d="m12.67 5.5 8.66 5"/>
  <path d="m12.67 10.5 8.66-5"/>
  <path d="M9 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2z"/>
</svg></button></div></div>
                    <div data-slot="log-viewer-range" class="relative min-w-0 w-auto">
    <select           class="w-full min-w-0 appearance-none rounded-control border border-input bg-card ps-3 pe-9 text-body text-foreground min-h-[var(--nq-touch-min,0px)] transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] h-control-sm" aria-label="Time range" data-slot="log-viewer-range" x-model="rangeId">
                            <option value="15m"  >Last 15 minutes</option>
                    <option value="1h"  >Last hour</option>
                    <option value="24h"  >Last 24 hours</option>
                    <option value="7d"  >Last 7 days</option>
                    <option value="all"   selected >All time</option>
    </select>
    <svg aria-hidden="true" class="pointer-events-none absolute end-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m6 9 6 6 6-6"/>
</svg></div>
                <div role="group" aria-label="Filter by level" class="flex flex-wrap items-center gap-1">
                            <button type="button" data-level="trace" x-on:click="toggleLevel('trace')" x-bind:aria-pressed="isOn('trace') ? 'true' : 'false'" x-bind:class="chipClass('trace')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('trace') ? levelClass('trace') : ''">Trace</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('trace')"></span>
                </button>
                            <button type="button" data-level="debug" x-on:click="toggleLevel('debug')" x-bind:aria-pressed="isOn('debug') ? 'true' : 'false'" x-bind:class="chipClass('debug')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('debug') ? levelClass('debug') : ''">Debug</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('debug')"></span>
                </button>
                            <button type="button" data-level="info" x-on:click="toggleLevel('info')" x-bind:aria-pressed="isOn('info') ? 'true' : 'false'" x-bind:class="chipClass('info')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('info') ? levelClass('info') : ''">Info</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('info')"></span>
                </button>
                            <button type="button" data-level="warn" x-on:click="toggleLevel('warn')" x-bind:aria-pressed="isOn('warn') ? 'true' : 'false'" x-bind:class="chipClass('warn')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('warn') ? levelClass('warn') : ''">Warn</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('warn')"></span>
                </button>
                            <button type="button" data-level="error" x-on:click="toggleLevel('error')" x-bind:aria-pressed="isOn('error') ? 'true' : 'false'" x-bind:class="chipClass('error')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('error') ? levelClass('error') : ''">Error</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('error')"></span>
                </button>
                            <button type="button" data-level="fatal" x-on:click="toggleLevel('fatal')" x-bind:aria-pressed="isOn('fatal') ? 'true' : 'false'" x-bind:class="chipClass('fatal')"
                    class="inline-flex h-control-sm items-center gap-1.5 rounded-control border px-2 text-caption outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                    <span class="font-mono" x-bind:class="isOn('fatal') ? levelClass('fatal') : ''">Fatal</span>
                    <span class="tabular-nums text-muted-foreground" x-text="levelCount('fatal')"></span>
                </button>
                    </div>
        <div class="ms-auto flex items-center gap-0.5">
                            <button data-slot="log-viewer-live"
     type="button"                         x-on:click="setLive(paused)" x-bind:aria-label="liveLabel" x-bind:aria-pressed="paused ? &#039;true&#039; : &#039;false&#039;" x-bind:data-active="paused ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 data-active:bg-nq-selected">
        <svg data-slot="icon" aria-hidden="true" x-show="! paused" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <rect x="14" y="3" width="5" height="18" rx="1"/>
  <rect x="5" y="3" width="5" height="18" rx="1"/>
</svg>                    <svg style="display: none;" data-slot="icon" aria-hidden="true" x-show="paused" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>
</svg></button>
                        <button data-slot="button"
     type="button"                         aria-label="Show timestamps" x-on:click="showTime = ! showTime" x-bind:aria-pressed="showTime ? &#039;true&#039; : &#039;false&#039;" x-bind:data-active="showTime ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 data-active:bg-nq-selected">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="12" cy="12" r="10"/>
  <path d="M12 6v6l4 2"/>
</svg></button>
            <button data-slot="button"
     type="button"                         aria-label="Follow new logs" x-on:click="setFollowing(! following)" x-bind:aria-pressed="following ? &#039;true&#039; : &#039;false&#039;" x-bind:data-active="following ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 data-active:bg-nq-selected">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M12 17V3"/>
  <path d="m6 11 6 6 6-6"/>
  <path d="M19 21H5"/>
</svg></button>
            <button data-slot="button"
     type="button"                         aria-label="Copy visible logs" x-on:click="copyVisible()" x-bind:data-copied="flash === strings.copied ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 data-copied:text-nq-success-text">
        <svg data-slot="icon" aria-hidden="true" x-show="flash !== strings.copied" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
  <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>                <svg style="display: none;" data-slot="icon" aria-hidden="true" x-show="flash === strings.copied" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg></button>
            <button data-slot="button"
     type="button"                         aria-label="Download logs" x-on:click="download()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M12 15V3"/>
  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
  <path d="m7 10 5 5 5-5"/>
</svg></button>
        </div>
    </div>
    <p x-show="invalid" style="display: none" x-bind:id="uid + '-invalid'" class="border-b border-border px-3 py-1 text-caption text-nq-danger-text">Invalid regular expression</p>
    <div class="relative">
        <div x-ref="list" data-slot="log-viewer-list" role="log" aria-label="Log entries" aria-live="off" tabindex="0"
            x-bind:aria-activedescendant="current ? rowId(current) : null" style="height: 24rem" x-on:scroll="onScroll()" x-on:keydown="onKey($event)"
            class="overflow-auto font-mono text-code outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus">
            <div x-show="olderOpen && rows.length === 0" data-slot="log-viewer-older" class="flex items-center justify-center border-b border-border/60" style="height: 36px; display: none ">
                <span x-show="loadingOlder" style="display: none" role="status" class="inline-flex items-center gap-2 font-sans text-caption text-muted-foreground">
                    <svg data-slot="spinner" aria-hidden="true" class="animate-spin motion-reduce:animate-none size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>                    Loading older entries…
                </span>
                <button data-slot="button"
     type="button"                         x-show="! loadingOlder" x-on:click="loadOlder()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover h-control-sm px-2.5">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
  <path d="M3 3v5h5"/>
  <path d="M12 7v5l4 2"/>
</svg>                    Load older entries</button>
            </div>
            <div x-show="rows.length === 0"  style="display: none"  data-slot="empty-state" class="m-3 flex flex-col items-center justify-center gap-3 rounded-card border-0 px-6 py-12 text-center font-sans">
                <span data-slot="state-icon" class="inline-flex size-10 items-center justify-center rounded-control border border-border bg-card text-muted-foreground [&_svg]:size-5">
                    <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
  <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
</svg>                </span>
                <div class="flex max-w-sm flex-col gap-1">
                    <p class="text-label text-foreground" x-text="entries.length === 0 ? strings.emptyTitle : strings.noMatchTitle">No logs yet</p>
                    <p class="text-body-sm text-muted-foreground" x-text="entries.length === 0 ? strings.emptyBody : strings.noMatchBody">New entries show up here as they arrive.</p>
                </div>
                <button data-slot="button"
     type="button"                         x-show="canReset" style="display: none" x-on:click="reset()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border-border bg-card text-foreground hover:bg-nq-hover h-control-sm px-2.5">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg>                    Reset filters</button>
            </div>
            <div x-show="rows.length !== 0"  class="relative w-max min-w-full" x-bind:style="{ height: (head + rows.length * rowHeight) + 'px' }">
                <div class="absolute inset-x-0 top-0" x-show="olderOpen" >
                    <div data-slot="log-viewer-older" class="flex items-center justify-center border-b border-border/60" style="height: 36px">
                        <span x-show="loadingOlder" style="display: none" role="status" class="inline-flex items-center gap-2 font-sans text-caption text-muted-foreground">
                            <svg data-slot="spinner" aria-hidden="true" class="animate-spin motion-reduce:animate-none size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>                            Loading older entries…
                        </span>
                        <button data-slot="button"
     type="button"                         x-show="! loadingOlder" x-on:click="loadOlder()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover h-control-sm px-2.5">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
  <path d="M3 3v5h5"/>
  <path d="M12 7v5l4 2"/>
</svg>                            Load older entries</button>
                    </div>
                </div>
                <template x-for="item in visible" x-bind:key="item.entry.id">
                    <div role="listitem" x-bind:id="rowId(item.entry)" x-bind:aria-posinset="item.index + 1" x-bind:aria-setsize="rows.length"
                        x-bind:aria-current="item.entry.id === selected ? 'true' : null" x-bind:data-level="item.entry.level" x-bind:data-selected="item.entry.id === selected ? '' : null"
                        x-bind:title="rowTitle(item.entry)" x-bind:class="rowClass(item.entry)"
                        x-bind:style="{ position: 'absolute', insetInlineStart: '0', insetInlineEnd: '0', top: item.top + 'px', height: rowHeight + 'px' }"
                        x-on:click="toggleRow(item.entry.id)">
                        <span x-show="showTime" class="shrink-0 text-muted-foreground tabular-nums" x-text="time(item.entry)"></span>
                        <span class="w-[5ch] shrink-0" x-bind:class="levelClass(item.entry.level)" x-text="tag(item.entry.level)"></span>
                        <span x-show="item.entry.source" style="display: none" class="max-w-[16ch] shrink-0 truncate text-nq-info-text" x-text="item.entry.source"></span>
                        <span class="text-foreground">
                            <template x-for="(part, i) in pieces(item.entry)" x-bind:key="i">
                                <span>
                                    <mark x-show="part.match" style="display: none" class="rounded-[2px] bg-nq-accent/30 text-inherit" x-text="part.text"></mark>
                                    <span x-show="! part.match" x-text="part.text"></span>
                                </span>
                            </template>
                        </span>
                    </div>
                </template>
            </div>
        </div>
        <button data-slot="button"
     type="button"                         style="display: none" x-show="showResume" x-on:click="setLive(true)" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] h-control-sm px-2.5 absolute end-3 bottom-3 shadow-sm">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>
</svg>            <span x-text="waitingText"></span></button>
        <button data-slot="button"
     type="button"                         style="display: none" x-show="showJump" x-on:click="setFollowing(true)" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border-border bg-card text-foreground hover:bg-nq-hover h-control-sm px-2.5 absolute end-3 bottom-3 shadow-sm">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M12 17V3"/>
  <path d="m6 11 6 6 6-6"/>
  <path d="M19 21H5"/>
</svg>            Jump to latest</button>
    </div>
    <section x-show="current" style="display: none" data-slot="log-viewer-detail" aria-label="Entry details"
        class="max-h-48 shrink-0 overflow-auto border-t border-border bg-card p-3 font-mono text-code">
        <div class="mb-2 flex items-center justify-between gap-2">
            <span class="text-caption" x-bind:class="current ? levelClass(current.level) : ''">
                <span x-text="current ? tag(current.level) : ''"></span>
                <span class="text-muted-foreground" x-text="currentTime()"></span>
                <span x-show="current && current.source" style="display: none" class="text-nq-info-text" x-text="current ? ' ' + (current.source || '') : ''"></span>
            </span>
            <span class="flex items-center">
                <button data-slot="button"
     type="button"                         aria-label="Copy entry" x-on:click="copyEntry()" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
  <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg></button>
                <button data-slot="button"
     type="button"                         aria-label="Close details" x-on:click="select(null)" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg></button>
            </span>
        </div>
        <p class="whitespace-pre-wrap break-all text-foreground" x-text="current ? current.message : ''"></p>
        <dl x-show="currentFields.length" style="display: none" class="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-0.5">
            <template x-for="f in currentFields" x-bind:key="f[0]">
                <div class="contents">
                    <dt class="text-muted-foreground" x-text="f[0]"></dt>
                    <dd class="m-0 break-all text-foreground" x-text="f[1]"></dd>
                </div>
            </template>
        </dl>
    </section>
    <div data-slot="log-viewer-footer" class="flex h-8 items-center justify-between gap-2 border-t border-border px-3 text-caption text-muted-foreground">
        <span class="tabular-nums" x-text="count">2 entries</span>
        <span x-show="paused" style="display: none" role="status" class="inline-flex items-center gap-1 text-nq-warning-text">
            <svg data-slot="icon" aria-hidden="true" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <rect x="14" y="3" width="5" height="18" rx="1"/>
  <rect x="5" y="3" width="5" height="18" rx="1"/>
</svg>            Paused
            <span x-show="waiting > 0" style="display: none" class="tabular-nums text-muted-foreground"> · <span x-text="waitingText"></span></span>
        </span>
        <span x-show="streaming && ! paused"  class="inline-flex items-center gap-1 text-nq-success-text">
            <svg data-slot="spinner" aria-hidden="true" class="animate-spin motion-reduce:animate-none size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>            Live
        </span>
    </div>
    <span role="status" aria-live="polite" class="sr-only" x-text="flash"></span>
</div>
```
