vatt'ghern jaskier's ballads

Cookbook snippet · tier 2

時間軸刷桿

水平時間軸加一個可拖曳的把手,圖隨把手位置更新到對應時間點。

Live example

00:00 · 偵測。SLO error rate 飆過 0.2% 門檻,oncall 被 paged。dashboard 顯示 POST /webapi/entry 的 5xx 在 30s 內從 0 衝到 180 req/s
00:05 · 緩解。oncall 把 scgi worker pool 重啟、error rate 從 0.18% 回落到 baseline 0.001%。但只是把症狀 mask 掉,還沒鎖 root cause。
00:15 · 鎖 root cause。追到 APIRunner.cpp:983m_runner_reusable 在 scgi 模式下 latch 翻黑後鎖死;新版的 build 觸發了一段 dlopen 路徑,連鎖反應。
01:00 · postmortem 草稿。timeline + 5 whys 寫好,等 lead review。要強調的關鍵是「symptom 跟 cause 差 15 分鐘」的時間差。
04:00 · 收尾。action items 進 tracker:`env-guard` 重構、SLO 警報門檻調整、runbook 補上 dlopen handle cache 章節。incident close。

純 CSS scrubber:5 個 <input type="radio"> + :has(:checked) 驅動兩件事:

  1. 切 step 對應的 .stage panel display(舊 panel 收掉、新 panel 出來)。
  2. track 上 accent 填充線寬度從 0% → 20% → … → 80% 隨選中位置延伸,給出「時間流動」感。

鍵盤:Tab 進來、Arrow Left/Right 切 step。零 JS。

完整食譜 (HTML + JS · 複製改寫用)

Horizontal time axis with a draggable handle; the figure updates to show the state at the scrub position.

When to use

  • Walk a reader through a sequence of events with precise control
  • Inspect "state at time T" for any T
  • Annotated incident timeline with hover detail

Complete snippet (paste-and-tweak)

<figure class="vg-w-scrub-EXAMPLE">
  <style>
    .vg-w-scrub-EXAMPLE { display: grid; gap: var(--s-2); }
    .vg-w-scrub-EXAMPLE svg { width: 100%; height: auto; }
    .vg-w-scrub-EXAMPLE .state { font-family: var(--serif); font-size: var(--fs-sm); padding: var(--s-2); background: var(--bg-soft); border-left: 2px solid var(--accent); }
  </style>

  <svg viewBox="0 0 720 80">
    <line x1="40" y1="40" x2="680" y2="40" stroke="var(--muted-2)" stroke-width="1.5" />
    <!-- Event markers -->
    <g id="vg-w-scrub-EXAMPLE-events"></g>
    <!-- Scrub handle -->
    <line id="vg-w-scrub-EXAMPLE-handle-line" x1="40" y1="20" x2="40" y2="60" stroke="var(--accent)" stroke-width="2" />
    <circle id="vg-w-scrub-EXAMPLE-handle" cx="40" cy="40" r="8" fill="var(--accent)" cursor="grab" />
    <text id="vg-w-scrub-EXAMPLE-clock" x="40" y="14" text-anchor="middle" font-family="Manrope, sans-serif" font-size="11" fill="var(--ink)">t=0:00</text>
  </svg>

  <div class="state" id="vg-w-scrub-EXAMPLE-state">scrub to inspect state at each moment</div>

  <script>
    (function () {
      const root = document.querySelector('.vg-w-scrub-EXAMPLE');
      const svg = root.querySelector('svg');
      const handle = root.querySelector('#vg-w-scrub-EXAMPLE-handle');
      const handleLine = root.querySelector('#vg-w-scrub-EXAMPLE-handle-line');
      const clock = root.querySelector('#vg-w-scrub-EXAMPLE-clock');
      const stateDiv = root.querySelector('#vg-w-scrub-EXAMPLE-state');
      const eventsG = root.querySelector('#vg-w-scrub-EXAMPLE-events');

      // Event timeline: [t in seconds, label, state description]
      const events = [
        [0,   'start',    'system idle, all queues empty'],
        [60,  'spike',    'request rate triples'],
        [120, 'queue',    'queue depth at 80% capacity'],
        [180, 'reject',   'first 503 returned; queue saturated'],
        [240, 'autoscale','new replica online, queue draining'],
        [300, 'recovered','queue depth back below 10%'],
      ];
      const T_MAX = 300;
      const X0 = 40, X1 = 680;
      const xOf = t => X0 + (t / T_MAX) * (X1 - X0);

      // Place event markers
      for (const [t, label] of events) {
        const NS = 'http://www.w3.org/2000/svg';
        const c = document.createElementNS(NS, 'circle');
        c.setAttribute('cx', xOf(t)); c.setAttribute('cy', 40); c.setAttribute('r', 4);
        c.setAttribute('fill', 'var(--muted)');
        eventsG.appendChild(c);
        const tx = document.createElementNS(NS, 'text');
        tx.setAttribute('x', xOf(t)); tx.setAttribute('y', 70);
        tx.setAttribute('text-anchor', 'middle');
        tx.setAttribute('font-family', 'Manrope, sans-serif');
        tx.setAttribute('font-size', 10);
        tx.setAttribute('fill', 'var(--muted)');
        tx.textContent = label;
        eventsG.appendChild(tx);
      }

      function clamp(x) { return Math.max(X0, Math.min(X1, x)); }
      function clientToSvgX(clientX) {
        const pt = svg.createSVGPoint();
        pt.x = clientX; pt.y = 0;
        const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
        return clamp(svgPt.x);
      }
      function fmtTime(t) {
        const m = Math.floor(t / 60), s = Math.floor(t % 60);
        return `t=${m}:${String(s).padStart(2, '0')}`;
      }
      function update(x) {
        handle.setAttribute('cx', x);
        handleLine.setAttribute('x1', x); handleLine.setAttribute('x2', x);
        clock.setAttribute('x', x);
        const t = ((x - X0) / (X1 - X0)) * T_MAX;
        clock.textContent = fmtTime(t);
        // find the latest event whose time <= t
        let active = events[0];
        for (const e of events) if (e[0] <= t) active = e;
        stateDiv.textContent = active[2];
      }

      let dragging = false;
      handle.addEventListener('pointerdown', (e) => {
        dragging = true;
        handle.setPointerCapture(e.pointerId);
      });
      handle.addEventListener('pointermove', (e) => {
        if (!dragging) return;
        update(clientToSvgX(e.clientX));
      });
      handle.addEventListener('pointerup', () => { dragging = false; });

      update(X0);
    })();
  </script>
</figure>

Gotchas

  • Discrete events on a continuous axis: the scrubber is continuous but events are at discrete times — find the latest event ≤ t and display its state.
  • Click anywhere on the line to jump: extend the pointerdown listener to the entire <svg> not just the handle.
  • Mobile tap target: handle radius ≥ 8 in viewBox; if viewBox is pixel-equivalent at mobile width, that's ≥ 16px tap area (still small; increase to 12 for safety).
  • Event labels overlapping: at low timeline width, labels collide. Stagger label y positions or omit labels for adjacent events.