$sloprun.dev

Log Parser

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 4 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Paste raw nginx access logs and get a readable incident report: a regex-parsed table, a stacked status-code timeline, top clients and paths, and error-rate chips — all computed in your browser, nothing uploaded anywhere. It opens on 82 lines of real-shaped traffic hiding two stories, and the parser names both: a deploy that started returning 500s on /api/v2/checkout, and 45.155.205.233 walking twelve routes looking for .env and wp-login.php. It also accounts for its own failures: the three lines that don't match the regex are counted, quoted back with their line numbers, and folded into the stats rather than silently dropped. Click a finding to filter the table to that moment, then paste your own access log over the sample and watch every panel recompute.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/log-parser" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Log Parser — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/log-parser

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1563 lines 80.5 KB index.html
demos/log-parser/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log Parser — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end token block ──────────────────────────────────────────────────────── */

/* Status palette for charts. Derived from the tokens; 4xx is stepped away from
   --danger so the amber/red pair clears the CVD + normal-vision separation
   floors (validated: ΔE 24.1 normal, 17.0 protan in light mode). */
:root {
  --s2: var(--run);       /* 2xx — earned green, a genuine success state */
  --s3: #7A828C;          /* 3xx — deliberately recessive neutral        */
  --s4: #C98A00;          /* 4xx — client error (chart fill)             */
  --s4-ink: #946400;      /* 4xx as text — darkened to 4.7:1 on --accent-soft
                             (the table row-hover tint), not just on white   */
  --s5: var(--danger);    /* 5xx — server error                          */
  --danger-ink: #BE3530;  /* danger as small text — raw --danger lands at
                             4.4:1 on --danger-soft, so 9-12px tags/labels
                             sitting on that tint use this instead           */
  --accent-ink: #A54A00;  /* accent as small text — darkened so 11-13px UI
                             copy clears AA on --accent-soft / --danger-soft
                             tints, where raw --accent lands at 4.2:1        */
  --danger-soft: #FCECEB;
  --grid: rgba(26,29,33,.09);
  --shadow: 0 6px 20px rgba(26,29,33,.14);
  color-scheme: light;    /* native select / search / scrollbars follow us   */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --s3: #8B939D; --s4: #F0B454; --s4-ink: #F0B454; --accent-ink: var(--accent);
    --danger-ink: var(--danger);
    --danger-soft: #2E1A19; --grid: rgba(232,230,225,.10);
    --shadow: 0 6px 20px rgba(0,0,0,.45);
    color-scheme: dark;
  }
}
:root[data-theme="dark"] {
  --s3: #8B939D; --s4: #F0B454; --s4-ink: #F0B454; --accent-ink: var(--accent);
  --danger-ink: var(--danger);
  --danger-soft: #2E1A19; --grid: rgba(232,230,225,.10);
  --shadow: 0 6px 20px rgba(0,0,0,.45);
  color-scheme: dark;
}
:root[data-theme="light"] { color-scheme: light; }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0; padding: 0 16px 56px;
  line-height: 1.5; font-size: 15px;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; }
h1, h2, h3 { margin: 0; font-weight: 620; letter-spacing: -.01em; }
button, input, textarea, select { font: inherit; color: inherit; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}
::selection { background: var(--accent-soft); color: var(--ink); }

/* ── header ───────────────────────────────────────────────────────────────── */
.rail {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 14px 0 12px; border-bottom: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  letter-spacing: .04em; text-transform: uppercase;
}
.rail .sep { opacity: .5; }
.rail .spacer { flex: 1 1 auto; }
.pulse {
  width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft); flex: none;
}
header.hdr { padding: 26px 0 22px; }
.hdr h1 { font-size: clamp(26px, 5.5vw, 36px); }
.hdr .lede {
  margin: 10px 0 0; max-width: 62ch; color: var(--muted); font-size: 15.5px;
}
.hdr .lede b { color: var(--ink); font-weight: 600; }

/* ── buttons ──────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 8px 13px; border: 1px solid var(--line); border-radius: 8px;
  background: var(--surface); color: var(--ink);
  font-size: 13.5px; font-weight: 550; cursor: pointer;
  transition: border-color .12s, background .12s, color .12s;
}
.btn:hover { border-color: var(--muted); }
.btn:active { transform: translateY(1px); }
.btn[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.btn.primary:hover { opacity: .88; }
.btn.small { padding: 5px 10px; font-size: 12.5px; }
.btn:disabled { opacity: .42; cursor: not-allowed; }
.btn .k { font-family: var(--font-mono); font-size: 12px; }
select.btn { display: inline-block; }

/* ── receipt strip ────────────────────────────────────────────────────────── */
.receipt {
  display: flex; align-items: baseline; gap: 10px 14px; flex-wrap: wrap;
  margin: 18px 0 0; padding: 11px 14px;
  border: 1px solid var(--line); border-left: 3px solid var(--run);
  border-radius: var(--radius); background: var(--surface);
  font-family: var(--font-mono); font-size: 12.5px; color: var(--muted);
}
.receipt.bad { border-left-color: var(--danger); }
.receipt .ok { color: var(--run); font-weight: 700; }
.receipt.bad .ok { color: var(--danger); }
.receipt .v { color: var(--ink); }
.receipt #rcpText { flex: 1 1 0; min-width: 0; }

/* ── findings ─────────────────────────────────────────────────────────────── */
.finds { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.find {
  display: flex; align-items: flex-start; gap: 9px; width: 100%;
  padding: 10px 12px; text-align: left; cursor: pointer;
  border: 1px solid var(--line); border-left: 3px solid var(--danger);
  border-radius: var(--radius); background: var(--surface);
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; color: var(--muted);
  transition: border-color .12s, background .12s;
}
.find:hover { background: var(--danger-soft); }
.find .ic { color: var(--danger-ink); flex: none; font-weight: 700; }
.find .tx { flex: 1 1 0; min-width: 0; }   /* basis 0 keeps the icon on the same line */
.find b { color: var(--ink); font-weight: 700; }
.find .go {
  margin-left: auto; padding-left: 10px; color: var(--accent-ink);
  white-space: nowrap; font-size: 11.5px;
}
.find.ok { border-left-color: var(--run); cursor: default; }
.find.ok:hover { background: var(--surface); }
.find.ok .ic { color: var(--run); }
@media (max-width: 620px) {
  .find { flex-wrap: wrap; }
  .find .go { margin-left: 0; padding: 4px 0 0 24px; width: 100%; }
}

/* ── layout ───────────────────────────────────────────────────────────────── */
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 16px;
}
section { margin-top: 20px; }
.sec-head {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px;
}
.sec-head h2 { font-size: 14.5px; }
.sec-head .note {
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  letter-spacing: .03em;
}
.sec-head .grow { flex: 1 1 auto; }

/* ── stat chips ───────────────────────────────────────────────────────────── */
.chips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
@media (min-width: 560px) { .chips { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 780px) { .chips { grid-template-columns: repeat(4, 1fr); } }
.chip {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px 11px;
  position: relative; overflow: hidden;
}
.chip::before {
  content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line);
}
.chip .val {
  font-family: var(--font-mono); font-size: 25px; font-weight: 600;
  letter-spacing: -.02em; line-height: 1.15; font-variant-numeric: tabular-nums;
  display: block; word-break: break-word;
}
.chip .val .u { font-size: 14px; color: var(--muted); margin-left: 4px; font-weight: 500; }
.chip .lbl {
  display: block; margin-top: 3px;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted);
}
.chip.good::before { background: var(--run); }
.chip.good .val { color: var(--run); }
.chip.warn::before { background: var(--s4); }
.chip.warn .val { color: var(--s4-ink); }
.chip.bad::before  { background: var(--danger); }
.chip.bad .val  { color: var(--danger); }

/* ── chart ────────────────────────────────────────────────────────────────── */
.chart-wrap { position: relative; }
canvas#timeline {
  display: block; width: 100%; height: 208px; border-radius: 6px;
  cursor: crosshair; touch-action: pan-y;
}
canvas#timeline:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.tip {
  position: absolute; z-index: 5; pointer-events: none;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 8px; padding: 8px 10px; min-width: 132px;
  box-shadow: var(--shadow);
  font-family: var(--font-mono); font-size: 11.5px; line-height: 1.65;
  opacity: 0; transform: translateY(2px); transition: opacity .1s;
}
.tip.on { opacity: 1; }
.tip .t-h { color: var(--ink); font-weight: 700; border-bottom: 1px solid var(--line); padding-bottom: 4px; margin-bottom: 4px; }
.tip .t-r { display: flex; align-items: center; gap: 7px; color: var(--muted); }
.tip .t-r .n { margin-left: auto; color: var(--ink); font-variant-numeric: tabular-nums; }
.sw { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.lg {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 9px; border: 1px solid var(--line); border-radius: 999px;
  background: transparent; cursor: pointer;
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  transition: border-color .12s, background .12s;
}
.lg:hover { border-color: var(--muted); }
.lg[aria-pressed="true"] { border-color: currentColor; background: var(--accent-soft); }
.lg .n { color: var(--ink); font-variant-numeric: tabular-nums; }

/* ── two-up top lists ─────────────────────────────────────────────────────── */
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.rank { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.rank li { position: relative; }
.rank button {
  display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px;
  width: 100%; padding: 7px 9px; border: 0; border-radius: 6px;
  background: transparent; cursor: pointer; text-align: left; position: relative;
  overflow: hidden;
}
.rank button:hover { background: var(--accent-soft); }
.rank button[aria-pressed="true"] { background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); }
.rank .bar {
  position: absolute; left: 0; top: 0; bottom: 0; z-index: 0;
  background: var(--grid); border-radius: 0 5px 5px 0;
}
.rank .k, .rank .m { position: relative; z-index: 1; }
.rank .k {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rank .m {
  font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 7px;
}
.tag {
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em;
  text-transform: uppercase; padding: 2px 5px; border-radius: 4px;
  border: 1px solid currentColor; white-space: nowrap;
}
.tag.scan { color: var(--danger-ink); background: var(--danger-soft); }
.tag.crawl { color: var(--muted); }
.tag.auto { color: var(--muted); }

/* ── filters + table ──────────────────────────────────────────────────────── */
.tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
.search {
  flex: 1 1 190px; min-width: 0;
  padding: 8px 11px; border: 1px solid var(--line); border-radius: 8px;
  background: var(--bg); font-family: var(--font-mono); font-size: 12.5px;
}
.search::placeholder { color: var(--muted); opacity: .8; }
.fchips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.fchip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 6px 3px 9px; border-radius: 999px;
  border: 1px solid var(--accent); background: var(--accent-soft); color: var(--accent-ink);
  font-family: var(--font-mono); font-size: 11.5px; cursor: pointer;
}
.fchip .x { font-size: 13px; line-height: 1; opacity: .75; }
.fchip:hover .x { opacity: 1; }

.tscroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; min-width: 640px; font-size: 13px; }
thead th {
  position: sticky; top: 0; z-index: 2;
  background: var(--surface); border-bottom: 1px solid var(--line);
  text-align: left; padding: 0; white-space: nowrap;
}
thead th button, thead th .th {
  width: 100%; display: flex; align-items: center; gap: 5px;
  padding: 9px 10px; background: none; border: 0; cursor: pointer;
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted); text-align: left;
}
thead th .th { cursor: default; }
thead th button:hover { color: var(--ink); }
thead th button:hover .ar, thead th button:focus-visible .ar { opacity: .5; }
thead th .ar { opacity: 0; font-size: 9px; }
thead th[aria-sort] button .ar { opacity: 1; color: var(--accent-ink); }
tbody td {
  padding: 7px 10px; border-bottom: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 12.5px; vertical-align: top;
  white-space: nowrap;
}
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: var(--accent-soft); }
td.num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
td.path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--ink); }
td.dim { color: var(--muted); }
.st { display: inline-flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
.st .sw { border-radius: 2px; }
.st.c5 { color: var(--danger); font-weight: 700; }
.st.c4 { color: var(--s4-ink); font-weight: 600; }
.st.c2 { color: var(--run); }
.st.c3 { color: var(--muted); }
.empty { padding: 26px 14px; text-align: center; color: var(--muted); font-size: 13.5px; }

.pager {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-top: 11px;
}
.pager .pos {
  font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.pager .grow { flex: 1 1 auto; }

/* ── input panel ──────────────────────────────────────────────────────────── */
textarea#src {
  width: 100%; height: 190px; resize: vertical;
  padding: 12px; border: 1px solid var(--line); border-radius: 8px;
  background: var(--bg); color: var(--ink);
  font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
  white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 2;
}
details.skipped { margin-top: 12px; }
details.skipped summary {
  cursor: pointer; font-family: var(--font-mono); font-size: 12px; color: var(--muted);
  padding: 6px 0; list-style-position: inside;
}
details.skipped summary:hover { color: var(--ink); }
details.skipped[open] summary { color: var(--ink); }
.skiplist { margin: 6px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.skiplist li {
  border-left: 3px solid var(--danger); background: var(--danger-soft);
  border-radius: 0 6px 6px 0; padding: 7px 10px;
  font-family: var(--font-mono); font-size: 11.5px; color: var(--ink);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.skiplist .ln { color: var(--danger-ink); margin-right: 10px; }
.hint { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); margin: 9px 0 0; }
.hint code { color: var(--accent-ink); }
.status-msg { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.status-msg.ok { color: var(--run); }

footer.foot {
  margin-top: 34px; padding-top: 14px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
}
footer.foot .spacer { flex: 1 1 auto; }

.sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (max-width: 820px) { .two { grid-template-columns: 1fr; gap: 16px; } }
@media (max-width: 520px) {
  body { padding: 0 12px 44px; }
  .chip .val { font-size: 21px; }
  .hdr { padding: 20px 0 16px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important; transition-duration: .001ms !important;
  }
  .btn:active { transform: none; }
}
</style>
</head>
<body>
<div class="wrap">

  <div class="rail">
    <span class="pulse" aria-hidden="true"></span>
    <span>nginx access.log</span>
    <span class="sep">·</span>
    <span>combined format</span>
    <span class="sep">·</span>
    <span>parsed in your browser</span>
    <span class="spacer"></span>
    <button class="btn small" id="themeBtn" type="button" title="Toggle light / dark">
      <span aria-hidden="true">◐</span> <span id="themeLbl">Dark</span>
    </button>
  </div>

  <header class="hdr">
    <h1>Log Parser</h1>
    <p class="lede">
      Raw web-server logs in, a readable incident report out. This one is loaded with
      <b>82 lines of nginx traffic</b> that hide a bad deploy and a vulnerability scanner —
      see how fast you can spot both. Then paste your own; nothing leaves this tab.
    </p>
  </header>

  <div class="receipt" id="receipt" role="status">
    <span class="ok" id="rcpIcon">✓</span>
    <span id="rcpText">parsing…</span>
  </div>

  <div class="finds" id="finds" role="group" aria-label="What the parser found"></div>

  <section aria-labelledby="h-stats">
    <div class="sec-head">
      <h2 id="h-stats">At a glance</h2>
      <span class="note" id="statNote"></span>
    </div>
    <div class="chips" id="chips"></div>
  </section>

  <section aria-labelledby="h-time">
    <div class="sec-head">
      <h2 id="h-time">Status codes over time</h2>
      <span class="grow"></span>
      <span class="note" id="bucketNote"></span>
    </div>
    <div class="card">
      <div class="chart-wrap">
        <canvas id="timeline" tabindex="0" role="img"
          aria-describedby="tlHelp" aria-label="Stacked bar chart of requests per time bucket, split by status class"></canvas>
        <div class="tip" id="tip" aria-hidden="true"></div>
      </div>
      <div class="legend" id="legend"></div>
      <p class="hint" id="tlHelp">
        Hover or focus the chart and use <code>← →</code> to walk the buckets;
        <code>Enter</code> filters the table to that moment, <code>Esc</code> clears.
        Click a status pill to filter by class.
      </p>
      <p class="sr" id="tlLive" role="status" aria-live="polite"></p>
    </div>
  </section>

  <section class="two" aria-label="Top talkers">
    <div>
      <div class="sec-head"><h2>Top clients</h2><span class="note">by requests</span></div>
      <div class="card"><ul class="rank" id="topIps"></ul></div>
    </div>
    <div>
      <div class="sec-head"><h2>Top paths</h2><span class="note">by requests</span></div>
      <div class="card"><ul class="rank" id="topPaths"></ul></div>
    </div>
  </section>

  <section aria-labelledby="h-table">
    <div class="sec-head">
      <h2 id="h-table">Parsed requests</h2>
      <span class="grow"></span>
      <span class="note" id="tblCount"></span>
    </div>
    <div class="card">
      <div class="tools">
        <label class="sr" for="q">Filter requests</label>
        <input class="search" id="q" type="search" placeholder="filter: ip, path, status, agent…" autocomplete="off" spellcheck="false">
        <button class="btn small" id="csvBtn" type="button">Copy CSV</button>
        <span class="status-msg" id="csvMsg" role="status"></span>
      </div>
      <div class="fchips" id="fchips"></div>
      <div class="tscroll" id="tscroll" tabindex="0" role="region" aria-label="Parsed requests table (scrolls sideways)">
        <table id="tbl">
          <caption class="sr">Parsed log lines, paginated</caption>
          <thead>
            <tr>
              <th scope="col"><span class="th">#</span></th>
              <th scope="col" data-sort="ts"><button type="button">time <span class="ar">▼</span></button></th>
              <th scope="col" data-sort="ip"><button type="button">client <span class="ar">▼</span></button></th>
              <th scope="col"><span class="th">method</span></th>
              <th scope="col" data-sort="path"><button type="button">path <span class="ar">▼</span></button></th>
              <th scope="col" data-sort="status"><button type="button">status <span class="ar">▼</span></button></th>
              <th scope="col" data-sort="bytes"><button type="button">bytes <span class="ar">▼</span></button></th>
              <th scope="col"><span class="th">agent</span></th>
            </tr>
          </thead>
          <tbody id="tbody"></tbody>
        </table>
      </div>
      <div id="tblEmpty" class="empty" hidden></div>
      <div class="pager">
        <button class="btn small" id="prev" type="button">← Prev</button>
        <button class="btn small" id="next" type="button">Next →</button>
        <span class="pos" id="pos"></span>
        <span class="grow"></span>
        <label class="pos" for="per">rows
          <select id="per" class="btn small" style="padding:4px 6px">
            <option>12</option><option>25</option><option>50</option>
          </select>
        </label>
      </div>
    </div>
  </section>

  <section aria-labelledby="h-src">
    <div class="sec-head">
      <h2 id="h-src">Your logs</h2>
      <span class="grow"></span>
      <span class="note">re-parses as you type</span>
    </div>
    <div class="card">
      <label class="sr" for="src">Raw log lines</label>
      <textarea id="src" spellcheck="false" autocomplete="off" wrap="off"></textarea>
      <div class="tools" style="margin:11px 0 0">
        <button class="btn primary" id="parseBtn" type="button">Parse</button>
        <button class="btn" id="sampleBtn" type="button">Restore sample</button>
        <button class="btn" id="clearBtn" type="button">Clear</button>
      </div>
      <p class="hint">
        Reads combined <em>and</em> common log format:
        <code>ip - user [10/Aug/2026:13:52:04 +0000] "GET /path HTTP/1.1" 200 5821 "ref" "agent"</code>
      </p>
      <details class="skipped" id="skipBox" hidden>
        <summary id="skipSum"></summary>
        <ul class="skiplist" id="skipList"></ul>
      </details>
    </div>
  </section>

  <footer class="foot">
    <span>demo · sloprun.dev</span>
    <span class="spacer"></span>
    <span id="perf"></span>
  </footer>
</div>

<script>
(function () {
  "use strict";

  /* ── sample data ───────────────────────────────────────────────────────── */
  var SAMPLE = `203.0.113.41 - - [10/Aug/2026:13:52:04 +0000] "GET / HTTP/1.1" 200 5821 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
203.0.113.41 - - [10/Aug/2026:13:52:05 +0000] "GET /static/app.4f2a1c.js HTTP/1.1" 200 184203 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
203.0.113.41 - - [10/Aug/2026:13:52:05 +0000] "GET /static/app.9b3d7e.css HTTP/1.1" 200 28114 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
203.0.113.41 - - [10/Aug/2026:13:52:06 +0000] "GET /assets/hero-dark.webp HTTP/1.1" 200 96410 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:52:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
198.51.100.77 - - [10/Aug/2026:13:52:18 +0000] "GET /pricing HTTP/1.1" 200 7402 "https://news.ycombinator.com/" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
198.51.100.77 - - [10/Aug/2026:13:52:19 +0000] "GET /static/app.4f2a1c.js HTTP/1.1" 304 0 "https://sloprun.dev/pricing" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
203.0.113.41 - - [10/Aug/2026:13:52:26 +0000] "GET /api/v2/orders?limit=25 HTTP/1.1" 200 14882 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:52:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
66.249.66.1 - - [10/Aug/2026:13:52:45 +0000] "GET /blog/scaling-postgres-to-1tb HTTP/1.1" 200 31204 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
192.0.2.204 - - [10/Aug/2026:13:52:52 +0000] "GET /docs/quickstart HTTP/1.1" 200 19338 "https://www.google.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
192.0.2.204 - - [10/Aug/2026:13:52:55 +0000] "GET /static/app.9b3d7e.css HTTP/1.1" 304 0 "https://sloprun.dev/docs/quickstart" "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
10.0.0.14 - - [10/Aug/2026:13:53:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
198.51.100.77 - - [10/Aug/2026:13:53:14 +0000] "GET /login HTTP/1.1" 200 4118 "https://sloprun.dev/pricing" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
198.51.100.77 - - [10/Aug/2026:13:53:18 +0000] "POST /login HTTP/1.1" 302 0 "https://sloprun.dev/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
198.51.100.77 - - [10/Aug/2026:13:53:20 +0000] "GET /dashboard HTTP/1.1" 200 22940 "https://sloprun.dev/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
203.0.113.12 - - [10/Aug/2026:13:53:32 +0000] "GET / HTTP/1.1" 200 5821 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:53:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:53:41 +0000] "GET /favicon.ico HTTP/1.1" 404 548 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
192.0.2.204 - - [10/Aug/2026:13:53:56 +0000] "GET /api/v2/projects HTTP/1.1" 200 8823 "https://sloprun.dev/docs/quickstart" "curl/8.4.0"
10.0.0.14 - - [10/Aug/2026:13:54:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
66.249.66.1 - - [10/Aug/2026:13:54:15 +0000] "GET /sitemap.xml HTTP/1.1" 200 4402 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
203.0.113.41 - - [10/Aug/2026:13:54:24 +0000] "POST /api/v2/checkout HTTP/1.1" 200 612 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:54:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
198.51.100.77 - - [10/Aug/2026:13:54:50 +0000] "GET /api/v2/orders?limit=25 HTTP/1.1" 200 14882 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
192.0.2.204 - - [10/Aug/2026:13:54:59 +0000] "GET /docs/api-reference HTTP/1.1" 200 44201 "https://sloprun.dev/docs/quickstart" "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
10.0.0.14 - - [10/Aug/2026:13:55:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:55:23 +0000] "GET /pricing HTTP/1.1" 200 7402 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
45.155.205.233 - - [10/Aug/2026:13:55:30 +0000] "GET /wp-login.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:31 +0000] "GET /wp-admin/setup-config.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
2026/08/10 13:55:42 [error] 3821#3821: *1204 connect() failed (111: Connection refused) while connecting to upstream
45.155.205.233 - - [10/Aug/2026:13:55:31 +0000] "GET /.env HTTP/1.1" 403 199 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:32 +0000] "GET /.git/config HTTP/1.1" 403 199 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:32 +0000] "GET /phpmyadmin/index.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:33 +0000] "POST /xmlrpc.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
10.0.0.14 - - [10/Aug/2026:13:55:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
45.155.205.233 - - [10/Aug/2026:13:55:45 +0000] "GET /admin/config.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:46 +0000] "GET /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:47 +0000] "GET /.aws/credentials HTTP/1.1" 403 199 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:55:48 +0000] "GET /backup.sql HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
203.0.113.41 - - [10/Aug/2026:13:55:55 +0000] "GET /dashboard HTTP/1.1" 200 22940 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:56:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:56:16 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
203.0.113.12 - - [10/Aug/2026:13:56:19 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
198.51.100.77 - - [10/Aug/2026:13:56:25 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
192.0.2.204 - - [10/Aug/2026:13:56:30 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "-" "curl/8.4.0"
10.0.0.14 - - [10/Aug/2026:13:56:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:56:41 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
203.0.113.41 - - [10/Aug/2026:13:56:45 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
198.51.100.77 - - [10/Aug/2026:13:56:52 +0000] "GET /api/v2/orders?limit=25 HTTP/1.1" 502 167 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
192.0.2.204 - - [10/Aug/2026:13:56:58 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "-" "curl/8.4.0"
203.0.113.41 - - [10/Aug/2026:13:57:05 +0000] "GET /api/v2/orders?limit=25 HTTP/1.1" 503 290 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:57:07 +0000] "GET /health HTTP/1.1" 500 1204 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:57:15 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
66.249.66.1 - - [10/Aug/2026:13:57:22 +0000] "GET /blog/index HTTP/1.1" 200 12880 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
198.51.100.77 - - [10/Aug/2026:13:57:29 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
10.0.0.14 - - [10/Aug/2026:13:57:37 +0000] "GET /health HTTP/1.1" 500 1204 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:58:19 +0000] "POST /api/v2/checkout HTTP/1.1" 5
192.0.2.204 - - [10/Aug/2026:13:57:44 +0000] "GET /status HTTP/1.1" 200 1044 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
203.0.113.41 - - [10/Aug/2026:13:57:56 +0000] "POST /api/v2/checkout HTTP/1.1" 500 1204 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:58:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:13:58:15 +0000] "POST /api/v2/checkout HTTP/1.1" 200 612 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
198.51.100.77 - - [10/Aug/2026:13:58:23 +0000] "GET /api/v2/orders?limit=25 HTTP/1.1" 200 14882 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
203.0.113.41 - - [10/Aug/2026:13:58:28 +0000] "POST /api/v2/checkout HTTP/1.1" 200 612 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:58:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
192.0.2.204 - - [10/Aug/2026:13:58:45 +0000] "POST /api/v2/checkout HTTP/1.1" 201 344 "-" "curl/8.4.0"
203.0.113.12 - - [10/Aug/2026:13:58:56 +0000] "GET /dashboard HTTP/1.1" 200 22940 "https://sloprun.dev/pricing" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
10.0.0.14 - - [10/Aug/2026:13:59:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
45.155.205.233 - - [10/Aug/2026:13:59:14 +0000] "GET /actuator/env HTTP/1.1" 404 548 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
45.155.205.233 - - [10/Aug/2026:13:59:15 +0000] "GET /api/v1/../../etc/passwd HTTP/1.1" 400 166 "-" "Mozilla/5.0 (X11; Linux x86_64) zgrab/0.x"
66.249.66.1 - - [10/Aug/2026:13:59:22 +0000] "GET /blog/scaling-postgres-to-1tb HTTP/1.1" 304 0 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
198.51.100.77 - - [10/Aug/2026:13:59:31 +0000] "GET /settings/billing HTTP/1.1" 200 9611 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
10.0.0.14 - - [10/Aug/2026:13:59:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.41 - - [10/Aug/2026:13:59:45 +0000] "GET /api/v2/projects HTTP/1.1" 200 8823 "https://sloprun.dev/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"

192.0.2.204 - - [10/Aug/2026:13:59:54 +0000] "GET /docs/quickstart HTTP/1.1" 304 0 "https://www.google.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
10.0.0.14 - - [10/Aug/2026:14:00:07 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.12 - - [10/Aug/2026:14:00:13 +0000] "GET /assets/hero-dark.webp HTTP/1.1" 200 96410 "https://sloprun.dev/dashboard" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
198.51.100.77 - - [10/Aug/2026:14:00:21 +0000] "POST /api/v2/checkout HTTP/1.1" 200 612 "https://sloprun.dev/dashboard" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
87.236.176.9 - - [10/Aug/2026:14:00:32 +0000] "GET /robots.txt HTTP/1.1" 200 241 "-" "python-requests/2.31.0"
### rotated by logrotate at 14:00:00 ###
10.0.0.14 - - [10/Aug/2026:14:00:37 +0000] "GET /health HTTP/1.1" 200 17 "-" "kube-probe/1.29"
203.0.113.41 - - [10/Aug/2026:14:00:45 +0000] "GET /logout HTTP/1.1" 302 0 "https://sloprun.dev/dashboard" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"`;

  /* ── theme: prefers-color-scheme by default, platform postMessage wins ─── */
  var root = document.documentElement;
  var themeBtn = document.getElementById("themeBtn");
  var themeLbl = document.getElementById("themeLbl");

  function effectiveTheme() {
    var set = root.getAttribute("data-theme");
    if (set === "dark" || set === "light") return set;
    return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }
  function applyTheme(t) {
    if (t === "dark" || t === "light") root.setAttribute("data-theme", t);
    else root.removeAttribute("data-theme");
    themeLbl.textContent = effectiveTheme() === "dark" ? "Light" : "Dark";
    drawChart();
  }
  themeBtn.addEventListener("click", function () {
    applyTheme(effectiveTheme() === "dark" ? "light" : "dark");
  });
  window.addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme") applyTheme(d.theme);
  });
  if (window.matchMedia) {
    var mq = window.matchMedia("(prefers-color-scheme: dark)");
    var onScheme = function () { themeLbl.textContent = effectiveTheme() === "dark" ? "Light" : "Dark"; drawChart(); };
    if (mq.addEventListener) mq.addEventListener("change", onScheme);
    else if (mq.addListener) mq.addListener(onScheme);
  }

  /* ── parsing ───────────────────────────────────────────────────────────── */
  // ip  ident  user  [time]  "METHOD path proto"  status  bytes  ["referer" "agent"]
  var LINE_RE = /^(\S+)\s+(\S+)\s+(\S+)\s+\[([^\]]+)\]\s+"([A-Z]{3,8})\s+(\S+)(?:\s+([^"]*))?"\s+(\d{3})\s+(\d+|-)(?:\s+"([^"]*)"\s+"([^"]*)")?\s*$/;
  var TS_RE = /^(\d{1,2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})(?:\s+([+-])(\d{2})(\d{2}))?$/;
  var MONTHS = { jan:0, feb:1, mar:2, apr:3, may:4, jun:5, jul:6, aug:7, sep:8, oct:9, nov:10, dec:11 };

  function parseTs(raw) {
    var m = TS_RE.exec(raw.trim());
    if (!m) return null;
    var mo = MONTHS[m[2].toLowerCase()];
    if (mo === undefined) return null;
    var t = Date.UTC(+m[3], mo, +m[1], +m[4], +m[5], +m[6]);
    if (m[7]) {
      var off = (+m[8] * 60 + +m[9]) * 60000;
      t += (m[7] === "+" ? -off : off);
    }
    return t;
  }

  function agentName(ua) {
    if (!ua || ua === "-") return "—";
    var u = ua.toLowerCase();
    if (u.indexOf("googlebot") > -1) return "Googlebot";
    if (u.indexOf("bingbot") > -1) return "Bingbot";
    if (u.indexOf("zgrab") > -1) return "zgrab";
    if (u.indexOf("kube-probe") > -1) return "kube-probe";
    if (u.indexOf("curl/") > -1) return "curl";
    if (u.indexOf("wget") > -1) return "wget";
    if (u.indexOf("python-requests") > -1) return "python";
    if (u.indexOf("bot") > -1 || u.indexOf("spider") > -1 || u.indexOf("crawl") > -1) return "bot";
    if (u.indexOf("edg/") > -1) return "Edge";
    if (u.indexOf("chrome/") > -1) return "Chrome";
    if (u.indexOf("firefox/") > -1) return "Firefox";
    if (u.indexOf("iphone") > -1 || u.indexOf("ipad") > -1) return "Safari iOS";
    if (u.indexOf("safari/") > -1) return "Safari";
    return ua.split("/")[0].slice(0, 14);
  }
  function isRobot(ua) {
    var u = (ua || "").toLowerCase();
    return /bot|spider|crawl|zgrab|curl\/|wget|python-requests|scan|kube-probe|http-client/.test(u);
  }

  function parse(text) {
    var raw = String(text).split(/\r?\n/);
    var rows = [], bad = [], blank = 0;
    for (var i = 0; i < raw.length; i++) {
      var line = raw[i];
      if (!line.trim()) { blank++; continue; }
      var m = LINE_RE.exec(line.trim());
      if (!m) { bad.push({ n: i + 1, text: line }); continue; }
      var st = +m[8];
      rows.push({
        n: i + 1,
        ip: m[1],
        user: m[3] === "-" ? "" : m[3],
        tsRaw: m[4],
        ts: parseTs(m[4]),
        method: m[5],
        path: m[6],
        proto: m[7] || "",
        status: st,
        klass: (st / 100 | 0),
        bytes: m[9] === "-" ? 0 : +m[9],
        ref: m[10] || "-",
        ua: m[11] || "-",
        agent: agentName(m[11] || "-")
      });
    }
    return { rows: rows, bad: bad, blank: blank, total: raw.length - blank };
  }

  /* ── aggregation ───────────────────────────────────────────────────────── */
  var CLASSES = [
    { k: 2, label: "2xx", name: "success", varname: "--s2" },
    { k: 3, label: "3xx", name: "redirect", varname: "--s3" },
    { k: 4, label: "4xx", name: "client error", varname: "--s4" },
    { k: 5, label: "5xx", name: "server error", varname: "--s5" }
  ];
  var STEPS = [1e3, 5e3, 15e3, 30e3, 60e3, 3e5, 9e5, 18e5, 36e5, 216e5, 864e5];

  function aggregate(rows) {
    var byClass = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
    var ips = new Map(), paths = new Map();
    var bytes = 0, minT = Infinity, maxT = -Infinity, timed = 0;

    rows.forEach(function (r) {
      byClass[r.klass] = (byClass[r.klass] || 0) + 1;
      bytes += r.bytes;
      var ipRec = ips.get(r.ip);
      if (!ipRec) { ipRec = { key: r.ip, n: 0, err: 0, srv: 0, paths: new Set(), robot: false, agent: r.agent }; ips.set(r.ip, ipRec); }
      ipRec.n++;
      if (r.klass === 4) { ipRec.err++; ipRec.paths.add(r.path); }
      if (r.klass === 5) ipRec.srv++;
      if (isRobot(r.ua)) ipRec.robot = true;

      var pKey = r.path.split("?")[0];
      var pRec = paths.get(pKey);
      if (!pRec) { pRec = { key: pKey, n: 0, srv: 0, cli: 0, bytes: 0 }; paths.set(pKey, pRec); }
      pRec.n++; pRec.bytes += r.bytes;
      if (r.klass === 5) pRec.srv++;
      if (r.klass === 4) pRec.cli++;

      if (r.ts != null) {
        timed++;
        if (r.ts < minT) minT = r.ts;
        if (r.ts > maxT) maxT = r.ts;
      }
    });

    ips.forEach(function (v) {
      v.tag = null;
      if (v.err >= 4 && v.paths.size >= 4) v.tag = { c: "scan", t: "scanner" };
      else if (/bot|spider|crawl/.test((v.agent || "").toLowerCase())) v.tag = { c: "crawl", t: "crawler" };
      else if (v.robot) v.tag = { c: "auto", t: "automated" };
    });

    var buckets = [], step = 60000;
    if (timed > 0 && isFinite(minT)) {
      var span = Math.max(maxT - minT, 1);
      for (var s = 0; s < STEPS.length; s++) { step = STEPS[s]; if (span / step <= 30) break; }
      var start = Math.floor(minT / step) * step;
      var count = Math.floor((maxT - start) / step) + 1;
      // Never truncate the axis: widen the bucket until the window fits, so a
      // multi-year log loses resolution rather than losing rows.
      while (count > 220) {
        step *= 2;
        start = Math.floor(minT / step) * step;
        count = Math.floor((maxT - start) / step) + 1;
      }
      for (var b = 0; b < count; b++) buckets.push({ t: start + b * step, tot: 0, c: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } });
      rows.forEach(function (r) {
        if (r.ts == null) return;
        var idx = Math.floor((r.ts - start) / step);
        if (idx < 0 || idx >= buckets.length) return;
        buckets[idx].tot++;
        buckets[idx].c[r.klass] = (buckets[idx].c[r.klass] || 0) + 1;
      });
    }

    var rank = function (map) {
      return Array.from(map.values()).sort(function (a, b) { return b.n - a.n || (a.key < b.key ? -1 : 1); });
    };
    return {
      byClass: byClass, bytes: bytes, ips: rank(ips), paths: rank(paths),
      uniqueIps: ips.size, uniquePaths: paths.size,
      minT: isFinite(minT) ? minT : null, maxT: isFinite(maxT) ? maxT : null,
      multiDay: isFinite(minT) && isFinite(maxT) && Math.floor(minT / 864e5) !== Math.floor(maxT / 864e5),
      multiYear: isFinite(minT) && isFinite(maxT) && new Date(minT).getUTCFullYear() !== new Date(maxT).getUTCFullYear(),
      buckets: buckets, step: step, timed: timed
    };
  }

  /* ── formatting ────────────────────────────────────────────────────────── */
  var MON_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  function pad(n) { return n < 10 ? "0" + n : "" + n; }
  function plural(n, word) { return n === 1 ? word : word + "s"; }
  function dmon(t) { var d = new Date(t); return d.getUTCDate() + " " + MON_NAMES[d.getUTCMonth()]; }
  // Day + month is enough inside one year; add the year once the log crosses one.
  function dstamp(t) { return (AGG && AGG.multiYear) ? dmon(t) + " " + new Date(t).getUTCFullYear() : dmon(t); }
  function hhmmss(t) { var d = new Date(t); return pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()) + ":" + pad(d.getUTCSeconds()); }
  function hhmm(t) { var d = new Date(t); return pad(d.getUTCHours()) + ":" + pad(d.getUTCMinutes()); }
  // Bare clock time is ambiguous the moment a log crosses midnight.
  function stamp(t) { return (AGG && AGG.multiDay) ? dstamp(t) + " " + hhmmss(t) : hhmmss(t); }
  function bucketLabel(t, step) {
    if (step >= 864e5) return dstamp(t);                  // day+ buckets: date only
    if (step >= 216e5) return dstamp(t) + " " + hhmm(t);  // 6h+ buckets span days
    if (step < 60000) return hhmmss(t);
    return hhmm(t);
  }
  function fmtBytes(b) {
    if (b < 1024) return [String(b), "B"];
    if (b < 1048576) return [(b / 1024).toFixed(b < 10240 ? 1 : 0), "KB"];
    if (b < 1073741824) return [(b / 1048576).toFixed(b < 10485760 ? 1 : 0), "MB"];
    return [(b / 1073741824).toFixed(2), "GB"];
  }
  function fmtDur(ms) {
    var s = Math.round(ms / 1000);
    if (s >= 1728e2) { var dd = s / 86400; return (dd % 1 === 0 ? dd : dd.toFixed(1)) + "d"; }
    if (s >= 3600 && s % 3600 === 0) return (s / 3600) + "h";   // 3600s → "1h", not "60m"
    if (s >= 60 && s % 60 === 0 && s < 5400) return (s / 60) + "m"; // 60s → "1m", not "60s"
    if (s < 90) return s + "s";
    var m = Math.round(s / 60);
    if (m < 90) return m + "m";
    return (m / 60).toFixed(1) + "h";
  }
  function css(name) { return getComputedStyle(root).getPropertyValue(name).trim() || "#888"; }
  function focusAttr(container, attr, val) {
    var list = container.querySelectorAll("[" + attr + "]");
    for (var i = 0; i < list.length; i++) {
      if (list[i].getAttribute(attr) === val) { list[i].focus(); return true; }
    }
    return false;
  }
  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]; }); }

  /* ── state ─────────────────────────────────────────────────────────────── */
  var DATA = { rows: [], bad: [], blank: 0, total: 0 };
  var AGG = null;
  var filters = { klass: null, ip: null, path: null, bucket: null, q: "" };
  var sort = { key: "ts", dir: 1 };
  var page = 0, perPage = 12;
  var hoverIdx = -1, focusIdx = -1;

  var el = {
    receipt: document.getElementById("receipt"),
    rcpIcon: document.getElementById("rcpIcon"),
    rcpText: document.getElementById("rcpText"),
    chips: document.getElementById("chips"),
    finds: document.getElementById("finds"),
    statNote: document.getElementById("statNote"),
    bucketNote: document.getElementById("bucketNote"),
    legend: document.getElementById("legend"),
    topIps: document.getElementById("topIps"),
    topPaths: document.getElementById("topPaths"),
    tbody: document.getElementById("tbody"),
    tblEmpty: document.getElementById("tblEmpty"),
    tblCount: document.getElementById("tblCount"),
    fchips: document.getElementById("fchips"),
    pos: document.getElementById("pos"),
    prev: document.getElementById("prev"),
    next: document.getElementById("next"),
    per: document.getElementById("per"),
    q: document.getElementById("q"),
    src: document.getElementById("src"),
    skipBox: document.getElementById("skipBox"),
    skipSum: document.getElementById("skipSum"),
    skipList: document.getElementById("skipList"),
    canvas: document.getElementById("timeline"),
    tip: document.getElementById("tip"),
    live: document.getElementById("tlLive"),
    perf: document.getElementById("perf"),
    csvMsg: document.getElementById("csvMsg")
  };

  /* ── render: receipt + chips ───────────────────────────────────────────── */
  function renderReceipt(ms) {
    var ok = DATA.rows.length > 0;
    el.receipt.classList.toggle("bad", !ok);
    el.rcpIcon.textContent = ok ? "✓" : "!";
    var parts = [];
    parts.push('<span class="v">' + DATA.total + "</span>" + plural(DATA.total, " line") + " in");
    parts.push('<span class="v">' + DATA.rows.length + "</span> parsed");
    parts.push('<span class="v">' + DATA.bad.length + "</span> unreadable");
    if (AGG && AGG.minT != null) {
      parts.push('<span class="v">' + stamp(AGG.minT) + " → " + stamp(AGG.maxT) + "</span> UTC");
      parts.push('<span class="v">' + fmtDur(AGG.maxT - AGG.minT) + "</span> window");
    }
    el.rcpText.innerHTML = ok
      ? parts.join(' <span class="sep">·</span> ')
      : "nothing parsed — check the format, or press <b>Restore sample</b>";
    el.perf.textContent = ok ? DATA.rows.length + plural(DATA.rows.length, " row") + " in " + ms.toFixed(1) + " ms" : "";
  }

  function renderChips() {
    var parsedAny = DATA.rows.length > 0;
    var n = DATA.rows.length || 1;
    var c = AGG.byClass;
    var errRate = (c[5] / n) * 100;
    var okRate = (c[2] / n) * 100;
    var by = fmtBytes(AGG.bytes);
    var badPct = DATA.total ? (DATA.bad.length / DATA.total) * 100 : 0;

    var items = [
      { v: String(DATA.rows.length), u: "", l: "requests", cls: "" },
      { v: errRate.toFixed(1), u: "%", l: "5xx error rate", cls: c[5] > 0 ? "bad" : (parsedAny ? "good" : "") },
      { v: String(c[5]), u: "", l: "server errors", cls: c[5] > 0 ? "bad" : "" },
      { v: String(c[4]), u: "", l: "client errors", cls: c[4] > 0 ? "warn" : "" },
      { v: okRate.toFixed(0), u: "%", l: "2xx success", cls: parsedAny && okRate >= 95 ? "good" : "" },
      { v: String(AGG.uniqueIps), u: "", l: "unique clients", cls: "" },
      { v: by[0], u: by[1], l: "bytes served", cls: "" },
      { v: String(DATA.bad.length), u: "", l: "unparsed lines", cls: DATA.bad.length > 0 ? "warn" : (parsedAny ? "good" : "") }
    ];
    el.chips.innerHTML = items.map(function (it) {
      return '<div class="chip ' + it.cls + '"><span class="val">' + esc(it.v) +
        (it.u ? '<span class="u">' + it.u + "</span>" : "") +
        '</span><span class="lbl">' + it.l + "</span></div>";
    }).join("");
    el.statNote.textContent = !parsedAny ? "nothing to summarise yet"
      : DATA.bad.length ? badPct.toFixed(1) + "% of lines did not match"
      : "every line matched";
  }

  /* ── render: findings (computed, and each one is a filter) ─────────────── */
  function renderFindings() {
    var out = [];
    if (AGG.buckets.length) {
      var w = AGG.buckets.reduce(function (a, b) { return (b.c[5] || 0) > (a.c[5] || 0) ? b : a; }, AGG.buckets[0]);
      if ((w.c[5] || 0) >= 2) {
        var pc = {}, tot = 0;
        DATA.rows.forEach(function (r) {
          if (r.klass === 5 && r.ts != null && r.ts >= w.t && r.ts < w.t + AGG.step) {
            var k = r.path.split("?")[0]; pc[k] = (pc[k] || 0) + 1; tot++;
          }
        });
        var top = Object.keys(pc).sort(function (a, b) { return pc[b] - pc[a]; })[0];
        out.push({
          ok: false,
          html: "<b>" + w.c[5] + " of " + w.tot + "</b> requests failed at <b>" + bucketLabel(w.t, AGG.step) +
            "</b>" + (top ? ", mostly on <b>" + esc(top) + "</b>" : "") + " — that is a server-side incident, not client noise.",
          go: "show the window →",
          act: function () { filters.bucket = w.t; filters.klass = 5; }
        });
      }
    }
    var sc = AGG.ips.filter(function (i) { return i.tag && i.tag.c === "scan"; })[0];
    if (sc) {
      out.push({
        ok: false,
        html: "<b>" + esc(sc.key) + "</b> probed <b>" + sc.paths.size + " distinct paths</b> and collected <b>" +
          sc.err + " 4xx</b> — that is a vulnerability scanner walking your routes.",
        go: "show its requests →",
        act: function () { filters.ip = sc.key; }
      });
    }
    if (!out.length && DATA.rows.length) {
      out.push({ ok: true, html: "No error spikes and no scanning behaviour in this window.", go: "", act: null });
    }

    el.finds.innerHTML = "";
    out.forEach(function (f) {
      var b = document.createElement(f.act ? "button" : "div");
      if (f.act) b.type = "button"; else b.setAttribute("role", "status");
      b.className = "find" + (f.ok ? " ok" : "");
      b.innerHTML = '<span class="ic" aria-hidden="true">' + (f.ok ? "\u2713" : "\u26A0") + "</span><span class=\"tx\">" + f.html + "</span>" +
        (f.go ? '<span class="go">' + f.go + "</span>" : "");
      if (f.act) b.addEventListener("click", function () {
        filters.klass = filters.ip = filters.path = null; filters.bucket = null;
        filters.q = ""; el.q.value = "";
        f.act();
        page = 0;
        renderFilters(); renderTable(); renderRanks(); renderLegend(); drawChart();
        var tgt = document.getElementById("h-table");
        var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        if (tgt.scrollIntoView) tgt.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
      });
      el.finds.appendChild(b);
    });
  }

  /* ── render: legend ────────────────────────────────────────────────────── */
  function renderLegend() {
    el.legend.innerHTML = CLASSES.map(function (c) {
      var n = AGG.byClass[c.k] || 0;
      var on = filters.klass === c.k;
      return '<button class="lg" type="button" data-klass="' + c.k + '" aria-pressed="' + on + '"' +
        ' title="filter the table to ' + c.label + '"><span class="sw" style="background:var(' + c.varname + ')"></span>' +
        c.label + ' <span class="n">' + n + "</span></button>";
    }).join("");
    Array.prototype.forEach.call(el.legend.querySelectorAll("[data-klass]"), function (b) {
      b.addEventListener("click", function () {
        var k = b.getAttribute("data-klass");
        var keep = document.activeElement === b;
        filters.klass = filters.klass === +k ? null : +k;
        page = 0; renderFilters(); renderTable(); renderLegend();
        if (keep) focusAttr(el.legend, "data-klass", k);
      });
    });
  }

  /* ── render: rank lists ────────────────────────────────────────────────── */
  function renderRanks() {
    var maxIp = AGG.ips.length ? AGG.ips[0].n : 1;
    el.topIps.innerHTML = AGG.ips.slice(0, 6).map(function (r) {
      var on = filters.ip === r.key;
      var tag = r.tag ? '<span class="tag ' + r.tag.c + '">' + r.tag.t + "</span>" : "";
      var srv = r.srv ? '<span class="tag scan">' + r.srv + " 5xx</span>" : "";
      return '<li><button type="button" data-ip="' + esc(r.key) + '" aria-pressed="' + on + '">' +
        '<span class="bar" style="width:' + (r.n / maxIp * 100).toFixed(1) + '%"></span>' +
        '<span class="k">' + esc(r.key) + "</span>" +
        '<span class="m">' + tag + srv + "<span>" + r.n + "</span></span></button></li>";
    }).join("");

    var maxP = AGG.paths.length ? AGG.paths[0].n : 1;
    el.topPaths.innerHTML = AGG.paths.slice(0, 6).map(function (r) {
      var on = filters.path === r.key;
      var bad = r.srv ? '<span class="tag scan">' + r.srv + " 5xx</span>" : (r.cli ? '<span class="tag auto">' + r.cli + " 4xx</span>" : "");
      return '<li><button type="button" data-path="' + esc(r.key) + '" aria-pressed="' + on + '">' +
        '<span class="bar" style="width:' + (r.n / maxP * 100).toFixed(1) + '%"></span>' +
        '<span class="k">' + esc(r.key) + "</span>" +
        '<span class="m">' + bad + "<span>" + r.n + "</span></span></button></li>";
    }).join("");

    Array.prototype.forEach.call(el.topIps.querySelectorAll("[data-ip]"), function (b) {
      b.addEventListener("click", function () {
        var v = b.getAttribute("data-ip");
        var keep = document.activeElement === b;
        filters.ip = filters.ip === v ? null : v;
        page = 0; renderFilters(); renderTable(); renderRanks();
        if (keep) focusAttr(el.topIps, "data-ip", v);
      });
    });
    Array.prototype.forEach.call(el.topPaths.querySelectorAll("[data-path]"), function (b) {
      b.addEventListener("click", function () {
        var v = b.getAttribute("data-path");
        var keep = document.activeElement === b;
        filters.path = filters.path === v ? null : v;
        page = 0; renderFilters(); renderTable(); renderRanks();
        if (keep) focusAttr(el.topPaths, "data-path", v);
      });
    });
  }

  /* ── filtering + table ─────────────────────────────────────────────────── */
  function filtered() {
    var q = filters.q.trim().toLowerCase();
    return DATA.rows.filter(function (r) {
      if (filters.klass && r.klass !== filters.klass) return false;
      if (filters.ip && r.ip !== filters.ip) return false;
      if (filters.path && r.path.split("?")[0] !== filters.path) return false;
      if (filters.bucket != null) {
        if (r.ts == null) return false;
        if (r.ts < filters.bucket || r.ts >= filters.bucket + AGG.step) return false;
      }
      if (q) {
        var hay = r.ip + " " + r.method + " " + r.path + " " + r.status + " " + r.agent + " " + r.tsRaw;
        if (hay.toLowerCase().indexOf(q) === -1) return false;
      }
      return true;
    });
  }

  function renderFilters() {
    var chips = [];
    if (filters.klass) chips.push({ k: "klass", t: "status " + filters.klass + "xx" });
    if (filters.ip) chips.push({ k: "ip", t: "client " + filters.ip });
    if (filters.path) chips.push({ k: "path", t: "path " + filters.path });
    if (filters.bucket != null) chips.push({ k: "bucket", t: "at " + bucketLabel(filters.bucket, AGG.step) });
    if (filters.q.trim()) chips.push({ k: "q", t: '"' + filters.q.trim() + '"' });

    el.fchips.innerHTML = chips.map(function (c) {
      return '<button class="fchip" type="button" data-clear="' + c.k + '">' + esc(c.t) +
        ' <span class="x" aria-hidden="true">×</span><span class="sr">remove filter</span></button>';
    }).join("") + (chips.length > 1 ? '<button class="fchip" type="button" data-clear="all">clear all <span class="x" aria-hidden="true">×</span></button>' : "");

    Array.prototype.forEach.call(el.fchips.querySelectorAll("[data-clear]"), function (b) {
      b.addEventListener("click", function () {
        var k = b.getAttribute("data-clear");
        var keep = document.activeElement === b;
        if (k === "all") { filters.klass = filters.ip = filters.path = null; filters.bucket = null; filters.q = ""; el.q.value = ""; }
        else if (k === "q") { filters.q = ""; el.q.value = ""; }
        else if (k === "bucket") filters.bucket = null;
        else filters[k] = null;
        page = 0; renderFilters(); renderTable(); renderRanks(); renderLegend(); drawChart();
        if (keep) {
          var nextChip = el.fchips.querySelector(".fchip");
          (nextChip || el.q).focus();
        }
      });
    });
  }

  function sortRows(rows) {
    var k = sort.key, d = sort.dir;
    return rows.slice().sort(function (a, b) {
      var x = a[k], y = b[k];
      if (k === "ts") { x = x == null ? -Infinity : x; y = y == null ? -Infinity : y; }
      if (typeof x === "string") return d * (x < y ? -1 : x > y ? 1 : 0) || (a.n - b.n);
      return d * (x - y) || (a.n - b.n);
    });
  }

  function renderTable() {
    var rows = sortRows(filtered());
    var total = rows.length;
    var pages = Math.max(1, Math.ceil(total / perPage));
    if (page >= pages) page = pages - 1;
    if (page < 0) page = 0;
    var slice = rows.slice(page * perPage, page * perPage + perPage);

    el.tbody.innerHTML = slice.map(function (r) {
      var cls = "c" + r.klass;
      var v = CLASSES.filter(function (c) { return c.k === r.klass; })[0];
      var sw = v ? '<span class="sw" style="background:var(' + v.varname + ')"></span>' : "";
      return "<tr>" +
        '<td class="num dim">' + r.n + "</td>" +
        "<td>" + (r.ts != null ? stamp(r.ts) : '<span class="dim">?</span>') + "</td>" +
        "<td>" + esc(r.ip) + "</td>" +
        '<td class="dim">' + esc(r.method) + "</td>" +
        '<td class="path" title="' + esc(r.path) + '">' + esc(r.path) + "</td>" +
        '<td><span class="st ' + cls + '">' + sw + r.status + "</span></td>" +
        '<td class="num">' + (r.bytes ? r.bytes.toLocaleString("en-US") : "—") + "</td>" +
        '<td class="dim">' + esc(r.agent) + "</td>" +
        "</tr>";
    }).join("");

    el.tblEmpty.hidden = total !== 0;
    el.tblEmpty.textContent = DATA.rows.length === 0
      ? "Nothing parsed yet — paste log lines into Your logs below, or press Restore sample."
      : "No rows match these filters.";
    el.pos.textContent = total
      ? (page * perPage + 1) + "–" + Math.min(total, (page + 1) * perPage) + " of " + total
      : "0 of 0";
    el.tblCount.textContent = total === DATA.rows.length
      ? DATA.rows.length + plural(DATA.rows.length, " row")
      : total + " of " + DATA.rows.length + plural(DATA.rows.length, " row");
    el.prev.disabled = page === 0;
    el.next.disabled = page >= pages - 1;

    Array.prototype.forEach.call(document.querySelectorAll("thead th[data-sort]"), function (th) {
      var k = th.getAttribute("data-sort");
      if (k === sort.key) {
        th.setAttribute("aria-sort", sort.dir === 1 ? "ascending" : "descending");
        th.querySelector(".ar").textContent = sort.dir === 1 ? "▲" : "▼";
      } else th.removeAttribute("aria-sort");
    });
  }

  /* ── skipped lines ─────────────────────────────────────────────────────── */
  function renderSkipped() {
    if (!DATA.bad.length) { el.skipBox.hidden = true; return; }
    el.skipBox.hidden = false;
    el.skipSum.textContent = DATA.bad.length + " line" + (DATA.bad.length === 1 ? "" : "s") + " could not be parsed — show them";
    el.skipList.innerHTML = DATA.bad.slice(0, 40).map(function (b) {
      return '<li><span class="ln">L' + b.n + "</span>" + esc(b.text.slice(0, 300)) + "</li>";
    }).join("") + (DATA.bad.length > 40 ? '<li class="ln">…and ' + (DATA.bad.length - 40) + " more</li>" : "");
  }

  /* ── chart ─────────────────────────────────────────────────────────────── */
  var cv = el.canvas, ctx = cv.getContext("2d");
  var geom = { x0: 0, slot: 0, w: 0, h: 0, top: 0, bottom: 0 };

  function drawChart() {
    if (!AGG) return;
    var wrapW = cv.clientWidth || 600;
    var cssH = 208;
    var dpr = Math.min(window.devicePixelRatio || 1, 2);
    cv.width = Math.max(1, Math.round(wrapW * dpr));
    cv.height = Math.round(cssH * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, wrapW, cssH);

    var muted = css("--muted"), grid = css("--grid"), surface = css("--surface"), accent = css("--accent");
    var colors = { 1: muted, 2: css("--s2"), 3: css("--s3"), 4: css("--s4"), 5: css("--s5") };
    var padL = 34, padR = 6, padT = 12, padB = 26;
    var W = wrapW - padL - padR, H = cssH - padT - padB;
    var bs = AGG.buckets;

    ctx.font = "500 10px " + (getComputedStyle(root).getPropertyValue("--font-mono").trim() || "monospace");
    ctx.textBaseline = "middle";

    if (!bs.length) {
      ctx.fillStyle = muted; ctx.textAlign = "center";
      ctx.fillText("no timestamps to plot", wrapW / 2, cssH / 2);
      geom.slot = 0;
      return;
    }

    var max = 1;
    bs.forEach(function (b) { if (b.tot > max) max = b.tot; });
    var ticks = max <= 4 ? max : 4;
    var stepV = Math.max(1, Math.ceil(max / ticks));
    max = stepV * Math.ceil(max / stepV);

    // grid + y labels
    ctx.textAlign = "right";
    for (var g = 0; g <= max; g += stepV) {
      var y = padT + H - (g / max) * H;
      ctx.strokeStyle = grid; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(padL, Math.round(y) + .5); ctx.lineTo(padL + W, Math.round(y) + .5); ctx.stroke();
      ctx.fillStyle = muted;
      ctx.fillText(String(g), padL - 7, y);
    }

    var slot = W / bs.length;
    var bw = Math.max(2, Math.min(slot * 0.74, 46));
    geom = { x0: padL, slot: slot, w: W, h: H, top: padT, bottom: padT + H, bw: bw };

    // highlight column
    var hi = hoverIdx >= 0 ? hoverIdx : focusIdx;
    if (hi >= 0 && hi < bs.length) {
      ctx.fillStyle = css("--accent-soft");
      ctx.fillRect(padL + hi * slot, padT - 4, slot, H + 6);
    }
    if (filters.bucket != null) {
      var fi = Math.round((filters.bucket - bs[0].t) / AGG.step);
      if (fi >= 0 && fi < bs.length) {
        ctx.strokeStyle = accent; ctx.lineWidth = 1;
        ctx.strokeRect(Math.round(padL + fi * slot) + .5, padT - 4 + .5, Math.round(slot) - 1, H + 6);
      }
    }

    // stacked bars, 2px surface gap between segments, 4px rounded cap on top
    bs.forEach(function (b, i) {
      var x = padL + i * slot + (slot - bw) / 2;
      var yBase = padT + H;
      var order = [2, 3, 4, 5, 1];
      Object.keys(b.c).forEach(function (k) { if (order.indexOf(+k) === -1) order.push(+k); });
      var segs = [];
      order.forEach(function (k) { if (b.c[k]) segs.push({ k: k, n: b.c[k] }); });
      var topSeg = segs.length - 1;
      segs.forEach(function (s, si) {
        var hSeg = (s.n / max) * H;
        var yTop = yBase - hSeg;
        ctx.fillStyle = colors[s.k] || muted;
        var r = si === topSeg ? Math.min(4, bw / 2, hSeg) : 0;
        roundRect(ctx, x, yTop, bw, Math.max(hSeg, 1.2), r, r, 0, 0);
        ctx.fill();
        if (si < topSeg && hSeg > 4) {
          ctx.fillStyle = surface;
          ctx.fillRect(x, yTop - 1, bw, 2);
        }
        yBase = yTop;
      });
    });

    // x axis
    ctx.strokeStyle = grid; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(padL, padT + H + .5); ctx.lineTo(padL + W, padT + H + .5); ctx.stroke();
    ctx.fillStyle = muted; ctx.textAlign = "center";
    var minGap = Math.max(40, ctx.measureText(bucketLabel(bs[bs.length - 1].t, AGG.step)).width + 16);
    var every = Math.max(1, Math.ceil(minGap / slot));
    for (var i2 = 0; i2 < bs.length; i2 += every) {
      var cx = padL + i2 * slot + slot / 2;
      if (cx > padL + W - 12) break;
      ctx.fillText(bucketLabel(bs[i2].t, AGG.step), cx, padT + H + 13);
    }
  }

  function roundRect(c, x, y, w, h, r1, r2, r3, r4) {
    c.beginPath();
    c.moveTo(x + r1, y);
    c.lineTo(x + w - r2, y); c.quadraticCurveTo(x + w, y, x + w, y + r2);
    c.lineTo(x + w, y + h - r3); c.quadraticCurveTo(x + w, y + h, x + w - r3, y + h);
    c.lineTo(x + r4, y + h); c.quadraticCurveTo(x, y + h, x, y + h - r4);
    c.lineTo(x, y + r1); c.quadraticCurveTo(x, y, x + r1, y);
    c.closePath();
  }

  function idxFromX(px) {
    if (!geom.slot) return -1;
    var i = Math.floor((px - geom.x0) / geom.slot);
    return (i >= 0 && i < AGG.buckets.length) ? i : -1;
  }

  function otherCount(b) {
    var known = 0;
    CLASSES.forEach(function (c) { known += b.c[c.k] || 0; });
    return Math.max(0, b.tot - known);
  }

  function showTip(i, px) {
    var b = AGG.buckets[i];
    if (!b) { el.tip.classList.remove("on"); return; }
    var rows = CLASSES.filter(function (c) { return b.c[c.k]; }).map(function (c) {
      return '<div class="t-r"><span class="sw" style="background:var(' + c.varname + ')"></span>' +
        c.label + '<span class="n">' + b.c[c.k] + "</span></div>";
    }).join("");
    var other = otherCount(b);
    if (other) rows += '<div class="t-r"><span class="sw" style="background:var(--muted)"></span>other<span class="n">' + other + "</span></div>";
    if (!rows) rows = '<div class="t-r">no requests</div>';
    el.tip.innerHTML = '<div class="t-h">' + bucketLabel(b.t, AGG.step) + " · " + b.tot + " req</div>" + rows;
    el.tip.classList.add("on");
    var tw = el.tip.offsetWidth || 140;
    var maxX = cv.clientWidth - tw - 4;
    var x = Math.max(0, Math.min(px - tw / 2, maxX));
    el.tip.style.left = x + "px";
    el.tip.style.top = "4px";
  }
  function hideTip() { el.tip.classList.remove("on"); }

  cv.addEventListener("mousemove", function (e) {
    var r = cv.getBoundingClientRect();
    var px = e.clientX - r.left;
    var i = idxFromX(px);
    if (i !== hoverIdx) { hoverIdx = i; drawChart(); }
    if (i >= 0) showTip(i, px); else hideTip();
  });
  cv.addEventListener("mouseleave", function () { hoverIdx = -1; hideTip(); drawChart(); });
  cv.addEventListener("click", function (e) {
    var r = cv.getBoundingClientRect();
    var i = idxFromX(e.clientX - r.left);
    if (i < 0) return;
    focusIdx = i;
    setBucketFilter(AGG.buckets[i].t);
  });
  cv.addEventListener("keydown", function (e) {
    if (!AGG || !AGG.buckets.length) return;
    var k = e.key;
    if (k === "ArrowRight" || k === "ArrowLeft") {
      e.preventDefault();
      if (focusIdx < 0) focusIdx = k === "ArrowRight" ? 0 : AGG.buckets.length - 1;
      else focusIdx = Math.max(0, Math.min(AGG.buckets.length - 1, focusIdx + (k === "ArrowRight" ? 1 : -1)));
      announce(focusIdx); drawChart();
      showTip(focusIdx, geom.x0 + focusIdx * geom.slot + geom.slot / 2);
    } else if (k === "Home" || k === "End") {
      e.preventDefault();
      focusIdx = k === "Home" ? 0 : AGG.buckets.length - 1;
      announce(focusIdx); drawChart();
      showTip(focusIdx, geom.x0 + focusIdx * geom.slot + geom.slot / 2);
    } else if (k === "Enter" || k === " ") {
      e.preventDefault();
      if (focusIdx >= 0) setBucketFilter(AGG.buckets[focusIdx].t);
    } else if (k === "Escape") {
      if (filters.bucket != null) {
        e.preventDefault();
        filters.bucket = null; page = 0;
        renderFilters(); renderTable(); drawChart();
        el.live.textContent = "time filter cleared";
      }
    }
  });
  cv.addEventListener("blur", function () { hideTip(); });

  function setBucketFilter(t) {
    filters.bucket = filters.bucket === t ? null : t;
    page = 0;
    renderFilters(); renderTable(); drawChart();
    el.live.textContent = filters.bucket == null
      ? "time filter cleared"
      : "table filtered to " + bucketLabel(t, AGG.step);
  }

  function announce(i) {
    var b = AGG.buckets[i];
    if (!b) return;
    var bits = CLASSES.filter(function (c) { return b.c[c.k]; })
      .map(function (c) { return b.c[c.k] + " " + c.label; });
    if (otherCount(b)) bits.push(otherCount(b) + " other");
    el.live.textContent = bucketLabel(b.t, AGG.step) + ": " + b.tot + plural(b.tot, " request") +
      (bits.length ? " — " + bits.join(", ") : "");
  }

  function chartAria() {
    if (!AGG || !AGG.buckets.length) { cv.setAttribute("aria-label", "No timestamped requests to plot"); return; }
    var peak = AGG.buckets.reduce(function (a, b) { return b.tot > a.tot ? b : a; }, AGG.buckets[0]);
    var worst = AGG.buckets.reduce(function (a, b) { return (b.c[5] || 0) > (a.c[5] || 0) ? b : a; }, AGG.buckets[0]);
    var span = AGG.multiDay
      ? dstamp(AGG.minT) + " " + hhmm(AGG.minT) + " to " + dstamp(AGG.maxT) + " " + hhmm(AGG.maxT)
      : hhmm(AGG.minT) + " to " + hhmm(AGG.maxT);
    var s = "Requests per " + fmtDur(AGG.step) + " from " + span +
      " UTC, stacked by status class. Busiest bucket " + bucketLabel(peak.t, AGG.step) +
      " with " + peak.tot + plural(peak.tot, " request") + ".";
    if (worst.c[5]) s += " Most server errors at " + bucketLabel(worst.t, AGG.step) + " with " + worst.c[5] + " 5xx.";
    cv.setAttribute("aria-label", s);
  }

  /* ── pipeline ──────────────────────────────────────────────────────────── */
  function run(text) {
    var t0 = (performance && performance.now) ? performance.now() : 0;
    DATA = parse(text);
    AGG = aggregate(DATA.rows);
    var t1 = (performance && performance.now) ? performance.now() : 0;

    if (filters.bucket != null && (!AGG.buckets.length ||
      filters.bucket < AGG.buckets[0].t || filters.bucket > AGG.buckets[AGG.buckets.length - 1].t)) {
      filters.bucket = null;
    }
    if (filters.ip && !DATA.rows.some(function (r) { return r.ip === filters.ip; })) filters.ip = null;
    if (filters.path && !DATA.rows.some(function (r) { return r.path.split("?")[0] === filters.path; })) filters.path = null;
    if (filters.klass && !AGG.byClass[filters.klass]) filters.klass = null;
    focusIdx = -1; hoverIdx = -1; hideTip();

    renderReceipt(t1 - t0);
    renderChips();
    renderFindings();
    renderLegend();
    renderRanks();
    renderFilters();
    renderTable();
    renderSkipped();
    el.bucketNote.textContent = AGG.buckets.length
      ? AGG.buckets.length + plural(AGG.buckets.length, " bucket") + " · " + fmtDur(AGG.step) + " each"
      : "no timestamps";
    chartAria();
    drawChart();
  }

  /* ── wiring ────────────────────────────────────────────────────────────── */
  var debounce;
  el.src.addEventListener("input", function () {
    clearTimeout(debounce);
    debounce = setTimeout(function () { run(el.src.value); }, 220);
  });
  document.getElementById("parseBtn").addEventListener("click", function () {
    clearTimeout(debounce); run(el.src.value);
  });
  document.getElementById("sampleBtn").addEventListener("click", function () {
    clearTimeout(debounce);
    el.src.value = SAMPLE;
    el.src.scrollLeft = 0; el.src.scrollTop = 0;
    filters.klass = filters.ip = filters.path = null; filters.bucket = null;
    filters.q = ""; el.q.value = ""; page = 0;
    run(SAMPLE);
    resetSrcScroll();
  });
  document.getElementById("clearBtn").addEventListener("click", function () {
    clearTimeout(debounce);
    el.src.value = "";
    el.src.scrollLeft = 0; el.src.scrollTop = 0;
    filters.klass = filters.ip = filters.path = null; filters.bucket = null;
    filters.q = ""; el.q.value = ""; page = 0;
    run("");
    resetSrcScroll();
  });

  function resetSrcScroll() {
    try { el.src.setSelectionRange(0, 0); } catch (err) { /* not selectable */ }
    el.src.focus();
    el.src.scrollLeft = 0; el.src.scrollTop = 0;
    requestAnimationFrame(function () { el.src.scrollLeft = 0; el.src.scrollTop = 0; });
  }

  el.q.addEventListener("input", function () {
    filters.q = el.q.value; page = 0;
    renderFilters(); renderTable();
  });
  // A button that disables itself drops focus to <body>; hand it to its partner.
  el.prev.addEventListener("click", function () {
    var had = document.activeElement === el.prev;
    page--; renderTable();
    if (had && el.prev.disabled) el.next.focus();
  });
  el.next.addEventListener("click", function () {
    var had = document.activeElement === el.next;
    page++; renderTable();
    if (had && el.next.disabled) el.prev.focus();
  });
  el.per.addEventListener("change", function () { perPage = +el.per.value || 12; page = 0; renderTable(); });

  Array.prototype.forEach.call(document.querySelectorAll("thead th[data-sort] button"), function (b) {
    b.addEventListener("click", function () {
      var k = b.parentNode.getAttribute("data-sort");
      if (sort.key === k) sort.dir *= -1; else { sort.key = k; sort.dir = 1; }
      page = 0; renderTable();
    });
  });

  var csvMsgTimer;
  document.getElementById("csvBtn").addEventListener("click", function () {
    var rows = sortRows(filtered());
    var head = "line,time,ip,method,path,status,bytes,agent\n";
    var body = rows.map(function (r) {
      return [r.n, r.ts != null ? new Date(r.ts).toISOString() : r.tsRaw, r.ip, r.method,
        '"' + r.path.replace(/"/g, '""') + '"', r.status, r.bytes,
        '"' + r.agent.replace(/"/g, '""') + '"'].join(",");
    }).join("\n");
    var csv = head + body;
    var done = function (ok) {
      el.csvMsg.className = "status-msg" + (ok ? " ok" : "");
      el.csvMsg.textContent = ok ? "✓ " + rows.length + " rows copied" : "copy blocked — select the rows instead";
      clearTimeout(csvMsgTimer);
      csvMsgTimer = setTimeout(function () { el.csvMsg.textContent = ""; el.csvMsg.className = "status-msg"; }, 3000);
    };
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(csv).then(function () { done(true); }, function () { done(legacyCopy(csv)); });
        return;
      }
    } catch (err) { /* fall through */ }
    done(legacyCopy(csv));
  });

  function legacyCopy(text) {
    try {
      var ta = document.createElement("textarea");
      ta.value = text;
      ta.setAttribute("readonly", "");
      ta.style.cssText = "position:fixed;top:0;left:-9999px;opacity:0";
      document.body.appendChild(ta);
      ta.select();
      var ok = document.execCommand ? document.execCommand("copy") : false;
      document.body.removeChild(ta);
      return !!ok;
    } catch (err) { return false; }
  }

  var rAF;
  function onResize() {
    cancelAnimationFrame(rAF);
    rAF = requestAnimationFrame(function () { hideTip(); drawChart(); });
  }
  if (window.ResizeObserver) new ResizeObserver(onResize).observe(cv.parentNode);
  window.addEventListener("resize", onResize);

  /* ── boot ──────────────────────────────────────────────────────────────── */
  themeLbl.textContent = effectiveTheme() === "dark" ? "Light" : "Dark";
  el.src.value = SAMPLE;
  run(SAMPLE);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong