익스클루시브

Quiet Alarm <mv-quiet-alarm>

부재 감지기입니다. 평소에 일어나던 일(주문, 웹훅, 웹소켓 메시지, 센서 측정값, 로그 줄)이 멈추면 경보를 울립니다.

문화적 레퍼런스

실버 블레이즈, 아서 코난 도일 (1892, 책). 결정적인 단서는 한밤중 개에 얽힌 기묘한 사건입니다. 개는 아무것도 하지 않았고, 탐정은 일어났어야 할 일이 일어나지 않았다는 점에 주목합니다. UI에서 컴포넌트는 평소 무엇이 언제 일어나는지 학습하고, 예상된 신호가 도착하지 않는 바로 그때 경보를 울립니다.

작동 방식

부재 감지기: 평소 일어나던 일이 멈추면 경보를 울립니다(주문, 웹훅, 웹소켓 메시지, 센서 판독값, 로그 줄). 앱은 beat(), 이름이 지정된 DOM 이벤트(listen), 또는 감시 영역에 추가되는 행(watch)으로 신호를 전달합니다. 컴포넌트는 강건한 도착 간격 통계(최근 40개 간격의 중앙값과 90번째 백분위수, 이상값과 과거 장애는 제외)로 평소 리듬을 학습하거나 예상 간격을 받아들이고, 현재의 침묵이 평소보다 길어지면 learning → normal → overdue → silent로 단계를 올립니다: “Unusually quiet · no new orders for 14 min (usually every 2 min)”. 간결한 리듬 스트립은 최근 신호를 고정된 “now” 마커를 지나 왼쪽으로 흘러가는 눈금으로 그리고, 그 앞에 다음 신호가 예상되는 구간을 음영으로, 이어서 overdue와 silent 구간을 빗금으로 표시하므로, 경보가 울리기 전에 다음 신호가 늦어지고 있음을 볼 수 있습니다. 침묵은 활성 시간으로 측정합니다. 조용한 시간("22:00-07:00, Sat-Sun 00:00-24:00")과 예상된 일시 중지(pause("20m", { reason }))는 계산에 들어가지 않으며 띠로 표시됩니다. Snooze는 상태를 속이지 않고 경보를 음소거하며 다음 신호에서 끝납니다. 취소 가능한 mv-escalate로 앱이 설명할 수 있는 경보를 거부할 수 있고, 취소 가능한 mv-beat로 신호를 걸러 낼 수 있으며, mv-resume은 장애가 얼마나 지속되었는지 알려 줍니다. seed()는 서버 기록으로 리듬을 미리 채우고 persist는 새로고침 후에도 리듬을 유지하므로, 대시보드가 새로고침 뒤에 아무것도 모르는 상태가 되지 않습니다. 카드 변형과 인라인(헤더 또는 툴바) 변형을 제공합니다.

카테고리피드백
유형Web Component (<mv-quiet-alarm>)
상태안정
키트알림 피로 없는 모니터링
함께 설치되는 항목button
Keywordsexclusive, culture, absence, heartbeat, watchdog, monitoring, anomaly, silence, rhythm, inter-arrival, ops, dashboard, e-commerce, iot, websocket, webhook, alert, status, snooze, quiet-hours

When to use

  • An ops or e-commerce dashboard must notice when orders, sign-ups or payments quietly stop arriving, not just when errors appear
  • A webhook, websocket or job queue can fail silently and the only symptom is that its messages stop coming
  • IoT or monitoring screens show sensors that report on a rhythm and a missing reading matters more than a bad one
  • A support or chat backend should flag an unusually long lull in incoming conversations during business hours

Avoid when

  • The deadline is fixed and known in advance (a session or an offer that ends at a set time) → use Hatch instead
  • You need to show what did change while the user was away, not what failed to happen → use While Away instead
  • The content is edited by hand and should just look stale after a while without review → use Content Age instead

설치

node scripts/add.mjs quiet-alarm --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["quiet-alarm"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/quiet-alarm/quiet-alarm.js, components/quiet-alarm/quiet-alarm.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-quiet-alarm label="Orders · US storefront" subject="orders" expected="5m" listen="order:created"></mv-quiet-alarm>

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="qa-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #qa-demo { display:grid; gap:1rem; align-content:start }
    #qa-demo .qa-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.625rem .75rem .625rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #qa-demo .qa-brand { display:flex; align-items:center; gap:.625rem; min-width:0 }
    #qa-demo .qa-logo { display:grid; place-items:center; flex:none; width:1.875rem; height:1.875rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #qa-demo .qa-logo svg { width:1rem; height:1rem }
    #qa-demo .qa-brand b { display:block; font-size:.9375rem; letter-spacing:-.01em }
    #qa-demo .qa-brand span { display:block; color:var(--mv-fg-muted); font-size:.75rem }
    #qa-demo .qa-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
    #qa-demo .qa-panel { min-width:0; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #qa-demo .qa-panel h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .75rem; font-size:.8125rem; font-weight:600 }
    #qa-demo .qa-panel h4 span { color:var(--mv-fg-subtle); font-weight:500; font-size:.75rem }
    #qa-demo .qa-feed { list-style:none; margin:0; padding:0; display:grid; gap:.375rem }
    #qa-demo .qa-feed li { display:grid; grid-template-columns:4.25rem minmax(0,1fr) auto; gap:.75rem; align-items:center; padding:.4375rem .625rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); font-size:.8125rem; font-variant-numeric:tabular-nums }
    #qa-demo .qa-feed li:first-child { animation:qa-in var(--mv-duration-slow) var(--mv-ease-out) }
    #qa-demo .qa-feed .qa-id { color:var(--mv-fg-subtle); font:.75rem var(--mv-font-mono) }
    #qa-demo .qa-feed .qa-who { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #qa-demo .qa-feed .qa-who small { color:var(--mv-fg-muted); font-size:.75rem }
    #qa-demo .qa-feed .qa-amt { font-weight:600 }
    @keyframes qa-in { from { opacity:0; translate:0 -4px } }
    #qa-demo .qa-controls { display:grid; gap:.875rem }
    #qa-demo .qa-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap }
    #qa-demo .qa-row > span { font-size:.8125rem; color:var(--mv-fg-muted) }
    #qa-demo .qa-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #qa-demo .qa-log { list-style:none; margin:0; padding:.625rem .75rem; display:grid; gap:.25rem; min-height:4.75rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font:.75rem/1.5 var(--mv-font-mono) }
    #qa-demo .qa-log b { color:var(--mv-fg); font-weight:600 }
    #qa-demo .qa-note { margin:0; color:var(--mv-fg-subtle); font-size:.75rem; text-align:center }
    @media (prefers-reduced-motion: reduce) { #qa-demo .qa-feed li:first-child { animation:none } }
    @media (max-width:44rem) { #qa-demo .qa-grid { grid-template-columns:minmax(0,1fr) } }
  </style>

  <header class="qa-bar">
    <div class="qa-brand">
      <span class="qa-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9h16l-1.2 10.2a2 2 0 0 1-2 1.8H7.2a2 2 0 0 1-2-1.8z"/><path d="M8.5 9V7a3.5 3.5 0 0 1 7 0v2"/></svg></span>
      <div><b>Kestrel Market · Operations</b><span>US storefront · all systems</span></div>
    </div>
    <mv-quiet-alarm id="qa-sensor" variant="inline" label="Cold room 3" subject="readings" expected="3s" snooze-for="1m"></mv-quiet-alarm>
  </header>

  <div class="qa-grid">
    <mv-quiet-alarm id="qa-orders" label="Orders · US storefront" subject="orders" watch="#qa-feed" snooze-for="30s"></mv-quiet-alarm>
    <mv-quiet-alarm id="qa-hooks" label="Payment webhooks" subject="webhooks" listen="payment:webhook" listen-on="#qa-demo" snooze-for="20s"></mv-quiet-alarm>

    <section class="qa-panel" aria-labelledby="qa-feed-h">
      <h4 id="qa-feed-h">Live orders <span>newest first</span></h4>
      <ul class="qa-feed" id="qa-feed" aria-label="Latest orders">
        <li><span class="qa-id">#10481</span><span class="qa-who">Kenji Watanabe <small>· Osaka</small></span><span class="qa-amt">$42.90</span></li>
        <li><span class="qa-id">#10480</span><span class="qa-who">Sofia Rossi <small>· Milan</small></span><span class="qa-amt">$128.00</span></li>
        <li><span class="qa-id">#10479</span><span class="qa-who">Amara Okafor <small>· Lagos</small></span><span class="qa-amt">$64.50</span></li>
        <li><span class="qa-id">#10478</span><span class="qa-who">Lucas Martin <small>· Lyon</small></span><span class="qa-amt">$19.99</span></li>
      </ul>
    </section>

    <section class="qa-panel qa-controls" aria-labelledby="qa-sim-h">
      <h4 id="qa-sim-h" style="margin:0">Simulation <span>time runs in seconds here</span></h4>
      <label class="mv-choice" data-control="end">
        <input type="checkbox" role="switch" class="mv-switch" id="qa-flow" checked>
        <span class="mv-choice-text"><span class="mv-choice-title">Orders flowing</span><span class="mv-choice-description">Turn off to simulate a broken checkout</span></span>
      </label>
      <div class="qa-row">
        <span id="qa-sens-l">Orders sensitivity</span>
        <mv-segmented id="qa-sens" aria-labelledby="qa-sens-l" value="normal">
          <button value="low">Low</button>
          <button value="normal">Normal</button>
          <button value="high">High</button>
        </mv-segmented>
      </div>
      <div class="qa-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="qa-deploy">Pause orders 20 s (deploy)</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="qa-hook">Deliver a webhook</button>
      </div>
      <ol class="qa-log" id="qa-log" aria-label="State changes"><li>State changes appear here.</li></ol>
    </section>
  </div>
  <p class="qa-note">Each monitor learns its own rhythm from the beats it sees and only speaks up when the silence is longer than usual.</p>

  <script type="module">
    const root = document.getElementById("qa-demo");
    await customElements.whenDefined("mv-quiet-alarm");
    const $ = (id) => document.getElementById(id);
    const orders = $("qa-orders"), hooks = $("qa-hooks"), sensor = $("qa-sensor");
    const feed = $("qa-feed"), log = $("qa-log");
    const timers = new Set();
    const later = (fn, ms) => { const t = setTimeout(() => { timers.delete(t); if (root.isConnected) fn(); }, ms); timers.add(t); };

    // Deterministic history so the page opens on a real rhythm.
    let s = 20260923;
    const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
    const history = (count, min, max, endAgo) => {
      const out = [];
      let t = Date.now() - endAgo;
      for (let i = 0; i < count; i++) { out.unshift(t); t -= (min + rnd() * (max - min)); }
      return out;
    };
    orders.seed(history(22, 1200, 3200, 900));
    hooks.seed(history(18, 1100, 1900, 5200)); // stopped five seconds ago
    sensor.seed(history(12, 2900, 3100, 1400));

    // Orders: rows appended to the watched feed are the beats.
    const people = [["Priya Nair", "Bengaluru"], ["Mateo García", "Madrid"], ["Emma Schneider", "Berlin"], ["Noah Williams", "Austin"], ["Aisha Rahman", "Dubai"], ["Chloé Dubois", "Montréal"], ["Liam O'Brien", "Dublin"], ["Yuna Kim", "Seoul"], ["Lucas Martin", "Lyon"], ["Amara Okafor", "Lagos"]];
    let id = 10481;
    const addOrder = () => {
      const [name, city] = people[id % people.length];
      const li = document.createElement("li");
      const a = Object.assign(document.createElement("span"), { className: "qa-id", textContent: `#${++id}` });
      const who = Object.assign(document.createElement("span"), { className: "qa-who", textContent: `${name} ` });
      who.append(Object.assign(document.createElement("small"), { textContent: `· ${city}` }));
      const amt = Object.assign(document.createElement("span"), { className: "qa-amt", textContent: `$${(12 + rnd() * 180).toFixed(2)}` });
      li.append(a, who, amt);
      feed.prepend(li);
      while (feed.children.length > 4) feed.lastElementChild.remove();
    };
    const flow = $("qa-flow");
    const orderLoop = () => { if (flow.checked) addOrder(); later(orderLoop, 1200 + rnd() * 2000); };
    later(orderLoop, 1300);

    // Sensor: the app calls beat() on every reading.
    const sensorLoop = () => { sensor.beat(); later(sensorLoop, 2900 + rnd() * 200); };
    later(sensorLoop, 1600);

    // Webhooks: a DOM event on the demo root is a beat.
    $("qa-hook").addEventListener("click", () => root.dispatchEvent(new CustomEvent("payment:webhook", { detail: { count: 1 } })));

    $("qa-deploy").addEventListener("click", () => orders.pause("20s", { reason: "a deploy" }));
    $("qa-sens").addEventListener("mv-change", (e) => { orders.sensitivity = e.detail.value; });

    const clock = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const names = { learning: "Learning", normal: "Normal", overdue: "Overdue", silent: "Silent", paused: "Paused" };
    root.addEventListener("mv-change", (e) => {
      if (e.target.localName !== "mv-quiet-alarm") return;
      const li = document.createElement("li");
      const b = Object.assign(document.createElement("b"), { textContent: e.target.label });
      li.append(`${clock.format(new Date())} · `, b, ` → ${names[e.detail.state]}`);
      if (!log.querySelector("b")) log.replaceChildren();
      log.prepend(li);
      while (log.children.length > 3) log.lastElementChild.remove();
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
labelstringActivityWhat is watched, shown as the title and used as the group's accessible name and in announcements (“Orders · US storefront”).
subjectstring (plural noun)eventsPlural noun used in sentences: “no new {subject} for 14 min”, “Waiting for {subject}”.
subject-onestringSingular noun (“Last {one} 40 sec ago”). Defaults to subject without its final s; set it for irregular nouns.
expectedtime ("2m", "30s", "1h", ms number)Known interval between beats. Skips learning, becomes the typical gap, and silence counts from the first connection even before any beat (“no new orders for 14 min” on a page that never saw one).
sensitivitylow | normal | high | numbernormalMultiplies the automatic thresholds: low = 1.5 (more tolerant), normal = 1, high = 0.7; a number is used as is. Overdue = k × max(2.5 × typical, 1.5 × p90); silent = max(1.6 × overdue, k × max(6 × typical, 3 × p90)).
overdue-after / silent-aftertimeAbsolute thresholds in active time, overriding the learned ones (silent is kept at least 1.25 × overdue).
learnnumber6Beats to see before the rhythm is trusted (the learning state shows “3 of 6 orders so far”). Ignored with expected.
memorynumber40How many recent gaps the statistics use, so the rhythm follows the time of day. Gaps interrupted by a pause or quiet hours, gaps longer than 12 × the median, and silences that reached silent are never learned; an overdue gap is, so a lasting slowdown is absorbed after a few beats.
quiet-hoursstringComma-separated local time ranges when silence is expected and never counts: "22:00-07:00" (overnight wraps), "Sat-Sun 00:00-24:00", "Mon 12:00-13:00". The state is paused while inside one (“Quiet hours · alerts resume at 7:00 AM”) and the ranges are drawn as bands on the strip.
listenstringEvent type(s), space- or comma-separated, that count as a beat ("order:created"). A numeric event.detail.count counts as a burst of that many.
listen-onwindow | document | self | CSS selectorwindowWhere the listen events are caught (window also receives bubbling events from anywhere in the page).
watchCSS selectorElement(s) whose new direct children count as beats (a feed, a log, a table body); rows added together are one beat of N. Elements matching later are picked up automatically; removals are ignored.
snooze-fortime30mDuration of the built-in Snooze button shown while overdue or silent, and default for snooze(). "0" hides the button.
spantimePast time range drawn on the strip. Auto by default: a round value (10 s … 7 d) covering about 12 typical gaps and the silent threshold; the future quarter to the right of now shows the expected-next window.
variantcard | inlinecardinline: one row (icon, label, short status, mini strip, icon-only actions) for headers, toolbars and table cells.
announce"off""off" silences the live-region announcements (visible status and events stay).
persiststringlocalStorage key suffix: the recent beats are saved and restored on reload, so learning is not lost (storage failures are ignored).
localeBCP 47en-USLocale for durations (Intl unit format) and clock times.
data-statelearning | normal | overdue | silent | pausedSet by the component (styleable). data-snoozed is added while snoozed.

Properties

Name유형Description
state"learning" | "normal" | "overdue" | "silent" | "paused"Current state (read-only).
silencenumber (ms)Active time since the last beat, quiet hours and pauses excluded (read-only).
typicalnumber (ms) | nullExpected interval, or the learned median gap; null while learning (read-only).
thresholds{ overdueAfter, silentAfter } | nullCurrent thresholds in active ms (read-only).
lastBeatDate | nullTime of the latest beat (read-only).
beats{ at: Date, count }[]Recorded beats, oldest first (read-only copy).
paused{ reason, until: Date | null } | nullCurrent pause or quiet period (reason "quiet-hours" for the latter).
snoozedUntilDate | nullEnd of the current snooze (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement, with {placeholders} (keys: learning, normal, overdue, silent, paused, snoozedPill, waiting, learningText, normalText, watchingText, overdueText, silentText, pausedText, pausedOpenText, quietText, snoozedSuffix, short*, justNow, ago, snooze, unsnooze, resume, spanAgo, now, strip, stripLast, stripNext, announceOverdue, announceSilent, announceResume, announceSnooze, announceUnsnooze). English defaults.
label / subject / expected / sensitivity / quietHours / listen / watch / snoozeFor / span / variant …reflectedMirror the attributes.

Methods

NameDescription
beat({ at?, count?, source? })Something happened (at defaults to now; a Date or ms number is also accepted directly). Beats less than 100 ms apart merge into one burst. Emits the cancelable mv-beat; returns false when vetoed.
seed(times)Primes the rhythm with past beats (Date, ms or { at, count }) from your server, without events, so the monitor starts in a real state.
pause(duration?, { reason? })Expected pause (deploy, maintenance, store closed): silence stops counting until it ends; open-ended without a duration. The state is paused with a Resume button.
resume()Ends the current pause now.
snooze(duration?)Mutes announcements and calms the alarm look for a while (default snooze-for), without changing the state; ends early on the next beat. Emits the cancelable mv-snooze.
unsnooze()Ends the snooze; an ongoing alarm is announced again.
reset()Forgets beats, pauses and snooze and starts learning again.

Events

NameDescription
mv-beatCancelable, before a beat is recorded. detail: { at, count, source: "api" | "event" | "mutation", event, nodes }. preventDefault() ignores it (e.g. keep only rows with a given class).
mv-escalateCancelable, before entering overdue or silent. detail: { from, to, silence, typical, overdueAfter, silentAfter }. preventDefault() vetoes that level until the next beat (a vetoed overdue can still become silent later).
mv-changeThe state changed. detail: { state, previous, silence, typical, snoozed, pause }.
mv-resumeA beat ended an alarm. detail: { silence (ms of active silence), previous ("overdue" | "silent"), at, count }.
mv-snoozeCancelable, before a snooze. detail: { until, duration }.

Content structure

NameDescription
actionsChildren with slot="actions" (a runbook link, “Open status page”) are placed next to the built-in Snooze / Resume buttons. Any other children are replaced by the generated UI.

CSS classes

NameDescription
mv-quiet-alarm-headRow with -icon (data-icon per state, one-shot ping on each beat, slow beacon while alarmed), -title, -pill (state word + icon) and -actions (.mv-quiet-alarm-snooze / -unsnooze / -resume).
mv-quiet-alarm-textThe status sentence; the part before “ · ” is in <strong>.
mv-quiet-alarm-striprole="img" rhythm strip: -track, -reel (slides with time), -tick (data-last, data-kind="resume", --_h = burst height), -silence (flat line since the last beat), -zone[data-zone="window | overdue | silent"], -usual (dashed typical mark), -band (pauses and quiet hours), -now, -axis.

CSS variables

NameDefaultDescription
--mv-quiet-alarm-normalvar(--mv-success)Normal state and expected-next window tint.
--mv-quiet-alarm-learningvar(--mv-info)Learning state tint.
--mv-quiet-alarm-overduevar(--mv-warning)Overdue state and zone.
--mv-quiet-alarm-silentvar(--mv-danger)Silent state and zone.
--mv-quiet-alarm-pausedvar(--mv-fg-subtle)Paused state.
--mv-quiet-alarm-height2.75remHeight of the strip (card variant).
--mv-quiet-alarm-inline-width6.5remWidth of the mini strip in the inline variant.

Accessibility

The element is a role="group" named by its label and described by the status sentence, which is always visible as text (in the inline variant a visually hidden copy of the full sentence is the description, the short text is aria-hidden). State is never conveyed by color alone: each state has its own icon and word in the pill (Learning, Normal, Overdue, Silent, Paused, plus “Snoozed”), the silence line changes from solid to dashed to thick, and the overdue and silent zones are hatched with different densities. The visible text updates every second but is not a live region, so nothing chatters; transitions are announced once in dedicated live regions: entering overdue politely, entering silent assertively, a resumption politely (“Orders: orders resumed after 14 minutes of silence”), snooze and unsnooze politely; announcements are skipped while snoozed and at page load, and announce="off" silences them. The rhythm strip is role="img" with a text summary (“22 orders in the last 45 seconds. Last one 2 sec ago. Next expected within 4 seconds.”) and is otherwise non-interactive. Snooze, Resume alerts and Resume are native buttons (icon-only with aria-label and title in the inline variant) that appear only when relevant; focus is never moved, except when a focused button disappears (Snooze once snoozed, Resume once resumed): focus then goes to the next visible action, or to the group itself, instead of the page. Reduced motion (OS or data-motion="reduce"): the strip steps instead of sliding, the beat ping, new-tick grow and alarm beacon are off. Forced colors: ticks, now marker, pill and borders use system colors; silent adds a thicker border. Timers are derived from Date.now() (robust to throttled background tabs, catches up on return), rendering pauses when the element is offscreen or the tab hidden while state changes and events still fire on time, and every listener, observer and timer stops when the element is removed.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고