miradb 0.1.0

Mira: an OTLP-native telemetry storage engine in a single binary
<script>
  import { api, bounds, parseFilter, fmtTime, fmtDur, fmtValue, sevName, svc, STATUS } from './api.js'
  import Detail from './Detail.svelte'
  import Empty from './Empty.svelte'

  let { signal, params, nonce, onstats } = $props()

  const PAGE = 200

  let rows = $state([])
  // The cursor the server returned with the last page, or '' for the end of the
  // result. Absent rather than null on the last page, so this is the whole of
  // the paging logic.
  let next = $state('')
  let loading = $state(true)
  let error = $state('')
  let open = $state(new Set())
  // `blocks_total` from the last successful response — how many blocks of this
  // signal exist before any predicate runs. Null until one arrives, so the
  // empty state never has to guess while a query is still in flight.
  let blocks = $state(null)

  // What the visible rows were fetched with. "Load more" pages exactly this, not
  // whatever has been typed into the boxes at the top since without pressing Run.
  let sent = null
  // A slow query that the user has already navigated away from must not
  // overwrite the results of the one they are looking at now, nor page into
  // them. Identity, not a boolean, so a page in flight knows which query it is.
  let live = { ok: true }
  // What is currently on screen, as a string. A re-run of the *same* query --
  // pressing Run, or a tick of follow mode -- has to leave the rows up until
  // the replacement arrives, or the table blanks for the length of a round trip
  // every few seconds. A different query does clear them: they are answers to a
  // question nobody is asking any more.
  let shown = null

  async function page(after) {
    const gen = live
    loading = true
    error = ''
    try {
      const doc = {
        signal: sent.sig,
        ...bounds(sent.range),
        where: parseFilter(sent.q, sent.sig),
        limit: PAGE,
      }
      if (after) doc.after = after
      const r = await api('/api/v1/query', doc)
      if (!gen.ok) return
      rows = after ? [...rows, ...r.rows] : r.rows
      // Detail rows are open by index, and a first page renumbers every row
      // under them, so a refresh that kept them open would expand a different
      // record than the one the reader clicked.
      if (!after) open = new Set()
      next = r.next || ''
      blocks = r.stats.blocks_total
      onstats(r.stats)
    } catch (e) {
      if (!gen.ok) return
      error = String(e.message || e)
      // A failed page keeps everything already on screen. `next` is unchanged,
      // so the button below is still pointed at the page that failed and one
      // click retries it; clearing the rows instead would throw away three
      // pages the user waited for because the fourth timed out, and the only
      // way back is re-running the whole query. Only a first page has nothing
      // to keep.
      if (!after) {
        rows = []
        next = ''
        blocks = null
        onstats(null)
      }
    } finally {
      if (gen.ok) loading = false
    }
  }

  $effect(() => {
    // Read every dependency up front so the effect re-runs on any of them, and
    // so nothing `page` touches after its first await is tracked by accident.
    sent = { q: params.q || '', range: params.range || '-1h', sig: signal }
    void nonce

    const gen = { ok: true }
    live = gen
    const id = `${sent.sig}\u0000${sent.q}\u0000${sent.range}`
    if (id !== shown) {
      rows = []
      next = ''
      blocks = null
      open = new Set()
    }
    shown = id
    page()
    return () => { gen.ok = false }
  })

  function toggle(i) {
    const next = new Set(open)
    next.has(i) ? next.delete(i) : next.add(i)
    open = next
  }

  const key = (r, i) => (signal === 'logs' ? r.time_unix_nano : r.span_id) + ':' + i

  // A row is a disclosure control, so it answers to what a button answers to.
  // Space is claimed rather than left alone because its default is to scroll
  // the page, which moves the row the user was about to open out from under
  // them.
  function activate(e, i) {
    if (e.key !== 'Enter' && e.key !== ' ') return
    e.preventDefault()
    toggle(i)
  }
</script>

<!-- The error sits above the rows rather than replacing them: a page that
     failed must not take the pages that succeeded with it. -->
{#if error}
  <div class="err">{error}</div>
{/if}

{#if !rows.length}
  {#if loading}
    <div class="empty">Loading…</div>
  {:else if !error}
    <Empty {signal} {blocks} q={params.q || ''} range={params.range || '-1h'} />
  {/if}
{:else}
  <table>
    <thead>
      <tr>
        {#if signal === 'logs'}
          <th>Time</th><th>Severity</th><th>Service</th><th>Body</th>
        {:else}
          <th>Time</th><th>Duration</th><th>Service</th><th>Span</th><th>Status</th>
        {/if}
      </tr>
    </thead>
    <tbody>
      {#each rows as row, i (key(row, i))}
        <!-- `tabindex` and `aria-expanded` on the row itself, not a `role`
             swapped onto it: `row` is one of the few roles that already
             supports `aria-expanded`, so this stays a row inside a table for a
             screen reader while becoming reachable by Tab. Opening it is also
             the only way to reach the "view trace" link it contains, which is
             what made the mouse-only version a dead end rather than an
             inconvenience. -->
        <tr
          tabindex="0"
          aria-expanded={open.has(i)}
          onclick={() => toggle(i)}
          onkeydown={(e) => activate(e, i)}
        >
          {#if signal === 'logs'}
            <td class="t">{fmtTime(row.time_unix_nano)}</td>
            <td class="sev-{sevName(row)}">{sevName(row).toUpperCase()}</td>
            <td>{svc(row)}</td>
            <!-- A body that is not a string is stored and returned as
                 `body_ser`, so reading only `body` blanks the column for
                 exactly the records whose body carries the most. -->
            <td class="msg">{fmtValue(row.body ?? row.body_ser)}</td>
          {:else}
            <td class="t">{fmtTime(row.start_time_unix_nano)}</td>
            <td class="num">{fmtDur(row.duration_nano)}</td>
            <td>{svc(row)}</td>
            <td class="msg">{row.name ?? ''}</td>
            <td class={row.status_code === 2 ? 'sev-error' : ''}>
              {STATUS[row.status_code] || ''}
            </td>
          {/if}
        </tr>
        {#if open.has(i)}
          <tr class="detail">
            <td colspan={signal === 'logs' ? 4 : 5}><Detail {row} /></td>
          </tr>
        {/if}
      {/each}
    </tbody>
  </table>
  <div class="more">
    <span>{rows.length} rows</span>
    {#if next}
      <!-- The same cursor, so after a failure this button is a retry of the
           page that failed rather than a skip past it. Saying so matters: a
           button reading "Load 200 more" under an error looks like the way
           forward, not the way to recover. -->
      <button onclick={() => page(next)} disabled={loading}>
        {loading ? 'Loading…' : error ? 'Retry' : `Load ${PAGE} more`}
      </button>
    {:else}
      <span>end of results</span>
    {/if}
  </div>
{/if}

<style>
  .more { display: flex; align-items: center; gap: 14px; padding: 14px 0; color: var(--dim); }
  .more button {
    font: inherit;
    color: var(--fg);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 6px;
    padding: 5px 12px;
    cursor: pointer;
  }
  .more button:disabled { color: var(--dim); cursor: default; }
</style>