Exclusivobeta
Cry Wolf <mv-cry-wolf>
Protección contra la fatiga de alertas para centros de notificaciones y consolas de monitorización: no se limita a mostrar alertas, decide cuáles merecen interrumpir. Cada fuente gana una puntuación de credibilidad según lo que el usuario hace realmente con sus alertas (actuar la sube; descartar sin actuar, “No es útil” y las repeticiones idénticas la bajan; las pruebas antiguas se desvanecen con una vida media, para que una fuente corregida recupere la confianza). Las fuentes por debajo del umbral se retienen en un resumen discreto (“4 alertas de baja relevancia de Disk monitor · Revisar”), las ráfagas se limitan por fuente y las alertas idénticas se agrupan en un contador (“×12 en 1 h”), pero una alerta marcada como crítica siempre pasa, en línea y anunciada de forma asertiva, con el historial de su fuente para ayudar a priorizar. Unas barras de señal muestran la credibilidad de cada fuente, un panel Sources muestra un medidor de confianza por fuente con los ajustes Mostrar siempre / Auto / Solo resumen, la propiedad sources expone las estadísticas para que los equipos corrijan las alertas ruidosas, mv-demote permite a la app vetar cualquier degradación, y el aprendizaje puede persistir por usuario.
| Categoría | Feedback |
|---|---|
| Tipo | Web Component (<mv-cry-wolf>) |
| Estado | beta |
| Kit | Supervisión sin fatiga de alertas |
| También instala | button |
| Keywords | exclusive, culture, notifications, alerts, alert-fatigue, notification-center, digest, deduplication, rate-limit, monitoring, ops, incident, credibility, trust, signal-to-noise, triage, feedback, preferences |
When to use
- A notification center or ops console receives more alerts than people read, and they start ignoring all of them
- Some alert sources are known to be noisy but must stay connected, and the real emergencies from them must still get through
- A team wants numbers on which alert sources nobody acts on, to fix or retire them
- In-app announcements an d system alerts compete for attention and eac h user should tune what interrupts the min
Avoid when
- A single transient confirmation or error must simply be displayed → use Toast instead
- A static callout inside the page (warning, maintenance notice) that is not a stream of alerts → use Alert instead
- Every alert is legally or operationally mandatory to show (safety, compliance): no learning should ever hold one back
Instalación
node scripts/add.mjs cry-wolf --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["cry-wolf"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/cry-wolf/cry-wolf.js, components/cry-wolf/cry-wolf.css, components/button/button.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-cry-wolf label="Operations alerts"></mv-cry-wolf>
<script type="module">
await customElements.whenDefined("mv-cry-wolf");
document.querySelector("mv-cry-wolf").push({ source: "disk", title: "Disk usage 91% on db-02", severity: "warning" });
</script>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="cw-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#cw-demo .cw-layout { display:grid; grid-template-columns:minmax(0,1fr) 18.5rem; gap:1.25rem; align-items:start }
#cw-demo .cw-console { padding:1.125rem 1.125rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg); box-shadow:var(--mv-shadow-sm) }
#cw-demo .cw-side { display:grid; gap:1rem }
#cw-demo .cw-card { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#cw-demo .cw-card h4 { margin:0; font-size:.8125rem; font-weight:600 }
#cw-demo .cw-card p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#cw-demo .cw-sim { display:grid; gap:.375rem }
#cw-demo .cw-sim .mv-button { justify-content:flex-start; width:100% }
#cw-demo .cw-sim .mv-button small { margin-inline-start:auto; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:500 }
#cw-demo .cw-report { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#cw-demo .cw-report li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.125rem .5rem; align-items:center; font-size:.75rem }
#cw-demo .cw-report b { font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#cw-demo .cw-report .cw-bar { grid-column:1 / -1; height:4px; border-radius:2px; background:var(--mv-bg-muted); overflow:hidden }
#cw-demo .cw-report .cw-bar i { display:block; height:100%; width:calc(var(--v) * 100%); border-radius:inherit; background:var(--mv-fg-muted) }
#cw-demo .cw-report .cw-num { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#cw-demo .cw-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#cw-demo .cw-log li { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#cw-demo .cw-log li:first-child { color:var(--mv-fg) }
#cw-demo .mv-choice { font-size:.8125rem }
@media (max-width:56rem) { #cw-demo .cw-layout { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="cw-layout">
<div class="cw-console">
<!-- Operations console: sources learn their credibility from what the on-call engineer does -->
<mv-cry-wolf id="cw-main" label="Operations alerts" rate-limit="2/30m" dedupe="1h"></mv-cry-wolf>
</div>
<aside class="cw-side" aria-label="Demo controls">
<div class="cw-card">
<h4>Simulate incoming alerts</h4>
<div class="cw-sim">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-noise">Disk warning <small>noisy source</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-pay">Payments incident <small>trusted</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-wolf">Critical disk alert <small>noisy + critical</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-burst">Same build failure ×6 <small>folded</small></button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="cw-sec">Admin sign-in <small>Always show</small></button>
</div>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cw-persist"> Remember per user (this browser)</label>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="cw-reset">Reset demo</button>
</div>
<div class="cw-card">
<h4>Noise report <span class="mv-badge" data-variant="secondary" data-size="sm">sources</span></h4>
<p>From the <code>sources</code> property: fix the alerts nobody acts on.</p>
<ul class="cw-report" id="cw-report"></ul>
</div>
<div class="cw-card">
<h4>Events</h4>
<ul class="cw-log" id="cw-log" aria-live="off"><li>Waiting for alerts…</li></ul>
</div>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-cry-wolf");
const cw = document.getElementById("cw-main");
const $ = (id) => document.getElementById(id);
const min = (n) => Date.now() - n * 60_000;
const SOURCES = [
{ id: "payments", label: "Payments API" },
{ id: "disk", label: "Disk monitor" },
{ id: "ci", label: "Build pipeline" },
{ id: "security", label: "Security scanner", trust: "always" },
{ id: "campaigns", label: "Campaign bot", trust: "digest" },
];
// Last week's reactions, as a server report would give them.
const HISTORY = {
version: 1,
sources: {
payments: { received: 16, acted: 11, dismissed: 3, notUseful: 0, record: "aadaaadaaada" },
disk: { received: 41, acted: 2, dismissed: 15, notUseful: 6, merged: 14, record: "ddndddnddadn" },
ci: { received: 22, acted: 6, dismissed: 5, notUseful: 1, record: "adaddaanddaa" },
security: { received: 8, acted: 3, dismissed: 4, notUseful: 1, record: "dadndad" },
campaigns: { received: 12, acted: 0, dismissed: 7, notUseful: 2, record: "ddnddddnd" },
},
};
const seed = () => {
cw.clear();
cw.sources = SOURCES;
if (!$("cw-persist").checked) cw.snapshot = HISTORY;
const backlog = [
{ source: "disk", title: "Disk usage 81% on web-03", severity: "warning", time: min(52) },
...[40, 31, 22, 12, 6].map((m) => ({ source: "disk", title: "Disk usage 81% on web-03", severity: "warning", time: min(m) })),
{ source: "ci", title: "Build #1287 failed on main", text: "3 tests failed in checkout-service after commit a41f9c2 by Priya Raman.", severity: "danger", time: min(28), actions: [{ label: "View logs", value: "logs" }] },
{ source: "campaigns", title: "Newsletter “Fall launch” reached a 38% open rate", severity: "success", time: min(35) },
{ source: "disk", title: "Inode usage 74% on cache-01", severity: "warning", time: min(30) },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(20) },
{ source: "campaigns", title: "A/B test “Hero copy” reached significance", severity: "info", time: min(20) },
{ source: "disk", title: "Swap above 60% on worker-07", severity: "warning", time: min(18) },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(11) },
{ source: "ci", title: "Flaky test quarantined: checkout.spec.ts", text: "Failed 4 of the last 20 runs. Owners: Payments team.", severity: "warning", time: min(12), actions: [{ label: "Open test", value: "test" }] },
{ source: "disk", title: "Log partition growing 4%/h on api-02", severity: "info", time: min(9) },
{ source: "ci", title: "Coverage dropped below 82% on main", severity: "info", time: min(8) },
{ source: "campaigns", title: "Unsubscribe rate +0.4% this week (within normal range)", severity: "info", time: min(6) },
{ source: "ci", title: "Dependency audit: 3 moderate advisories", severity: "warning", time: min(5) },
{ source: "payments", title: "Payout batch #4812 failed for 3 merchants", text: "The bank rejected the transfer file (code R03). $18,240.00 is on hold until the batch is retried.", severity: "warning", time: min(4), actions: [{ label: "Retry batch", value: "retry" }, { label: "View batch", value: "view" }] },
{ source: "ci", title: "Build #1287 failed on main", severity: "danger", time: min(3) },
{ source: "disk", title: "Database volume 97% full on db-primary-02", text: "Writes will start failing in about 20 minutes at the current rate.", severity: "danger", critical: true, time: min(1), actions: [{ label: "Expand volume", value: "expand" }, { label: "Open runbook", value: "runbook" }] },
];
cw.push(backlog, { silent: true });
report();
};
// Noise report built from the public stats.
const report = () => {
const list = $("cw-report");
list.replaceChildren(...cw.sources.map((s) => {
const li = document.createElement("li");
const name = document.createElement("b");
name.textContent = s.label;
const badge = document.createElement("span");
badge.className = "mv-badge";
badge.dataset.size = "sm";
const routed = s.trust === "always" ? ["Always", "info"] : s.trust === "digest" ? ["Digest only", "secondary"] : s.routing === "digest" ? ["Held", "warning"] : ["Shown", "success"];
badge.textContent = routed[0];
badge.dataset.variant = routed[1];
const bar = document.createElement("span");
bar.className = "cw-bar";
const fill = document.createElement("i");
fill.style.setProperty("--v", s.credibility);
bar.append(fill);
const num = document.createElement("span");
num.className = "cw-num";
num.textContent = `${Math.round(s.credibility * 100)}% · acted on ${s.actionRate == null ? "-" : `${Math.round(s.actionRate * 100)}%`}`;
li.append(name, badge, bar, num);
return li;
}));
};
const log = (text, key) => {
const li = document.createElement("li");
if (key) li.dataset.key = key;
li.textContent = `${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })} ${text}`;
const el = $("cw-log");
const first = el.firstElementChild;
if (first?.textContent === "Waiting for alerts…") el.replaceChildren();
// Repeats of the same alert update one line instead of flooding the log.
if (first && li.dataset.key && first.dataset.key === li.dataset.key) first.replaceWith(li);
else el.prepend(li);
while (el.children.length > 5) el.lastElementChild.remove();
};
const reasons = { credibility: "low credibility", rate: "rate limited", trust: "digest only" };
cw.addEventListener("mv-alert", (e) => {
const { alert, verdict, reason } = e.detail;
if (alert.last < Date.now() - 50_000) return; // backlog
log(verdict === "merged" ? `folded ×${alert.count} · ${alert.title}` : verdict === "digest" ? `held (${reasons[reason]}) · ${e.detail.source.label}` : `shown${alert.critical ? " (critical)" : ""} · ${alert.title}`, verdict === "merged" ? alert.id : null);
report();
});
cw.addEventListener("mv-feedback", (e) => {
const { kind, source, credibility, previous, value } = e.detail;
const delta = typeof previous === "number" ? ` ${Math.round(previous * 100)}% → ${Math.round(credibility * 100)}%` : "";
log(`${kind}${kind === "trust" ? ` = ${value}` : ""} · ${source.label}${delta}`);
report();
});
let n = 0;
$("cw-noise").addEventListener("click", () => {
const hosts = ["web-05", "web-07", "cache-02", "worker-03"];
cw.push({ source: "disk", title: `Disk usage ${82 + (n % 6)}% on ${hosts[n++ % hosts.length]}`, severity: "warning" });
});
$("cw-pay").addEventListener("click", () => {
cw.push({ source: "payments", title: "Card authorizations failing in EU-West", text: "Decline rate 14% over the last 5 minutes (usual: 2%). Acquirer: Adyen EU.", severity: "danger", actions: [{ label: "Open incident", value: "incident" }] });
});
$("cw-wolf").addEventListener("click", () => {
cw.push({ source: "disk", title: "Disk full on queue-01", text: "The message broker stopped accepting new jobs at 100% usage.", critical: true, actions: [{ label: "Free space", value: "free" }] });
});
$("cw-burst").addEventListener("click", () => {
let i = 0;
const t = setInterval(() => {
cw.push({ source: "ci", title: "Build #1287 failed on main", text: "Retry failed: the same 3 tests in checkout-service.", severity: "danger" });
if (++i >= 6) clearInterval(t);
}, 220);
});
$("cw-sec").addEventListener("click", () => {
cw.push({ source: "security", title: "New admin sign-in from São Paulo, Brazil", text: "Account: [email protected] · Chrome on macOS · MFA passed.", severity: "info", actions: [{ label: "Review session", value: "review" }] });
});
$("cw-persist").addEventListener("change", (e) => {
if (e.target.checked) cw.storageKey = "mv-demo-cry-wolf";
else { try { localStorage.removeItem("mv-demo-cry-wolf"); } catch {} cw.storageKey = null; }
});
$("cw-reset").addEventListener("click", () => { cw.resetLearning(); seed(); log("demo reset"); });
seed();
</script>
</div>Referencia cultural
El pastorcillo mentiroso, Esopo (h. 600 a. C., fábula). Un joven pastor da tantas falsas alarmas que, cuando el lobo llega de verdad, los aldeanos ya no le hacen caso. En la interfaz, cada fuente de alertas gana o pierde credibilidad según cómo reacciona la gente ante ella, las fuentes ruidosas se retienen discretamente en un resumen, y una alarma marcada como crítica siempre se oye, con el historial de su fuente.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
threshold | number (0..1) | 0.4 | Credibility under which an Auto source is held in the digest. It comes back once it climbs 0.1 above (hysteresis, no flapping). New sources start at 0.67 (benefit of the doubt): about three dismissals in a row demote a new source. |
rate-limit | "<count>/<time>" | "off" | 5/10m | Per source: at most count alerts shown inline within the sliding window; the next ones go to the digest with the reason “Rate limited”. Critical alerts and Always show sources are never limited. |
dedupe | time | "off" | 1h | Alerts with the same key (default: source + title) or the same id within this window fold into the existing one (inline or held): counter “×12 in 1 h”, latest time and text. Each repeat lowers the source’s credibility a little (0.2 of a dismissal). A critical repeat of a held alert escalates it inline. |
memory | time | "off" | 7d | Half-life of the learned evidence: reactions older than this weigh half as much, so a source that was fixed recovers on its own. off keeps everything forever. |
storage-key | string | Opt-in persistence: the learned state (credibility evidence, counters, record, trust overrides, never the alerts themselves) is saved to localStorage under this key, restored on connection and synced across tabs. Use one key per user. | |
label | string | Alerts | Heading shown in the toolbar; also the accessible name of the region. |
max | number | 50 | Maximum alerts kept. Beyond it, the oldest held alert is dropped first, then the oldest non-critical one. |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred side of the Sources panel relative to its button (flips and shifts to stay in the viewport). |
Properties
| Name | Tipo | Description |
|---|---|---|
sources | SourceStats[] (get) · { id, label?, trust? }[] (set) | Read: per-source stats, noisiest first: { id, label, trust, credibility (0..1), demoted, routing ("inline" | "digest"), received, shown, digested, merged, acted, dismissed, notUseful, actionRate (acted / reactions, null before any), record (last 12 reactions), pending (current alerts), lastAt }. Write: declare labels and default trust ("always" | "auto" | "digest") without touching the learning. |
snapshot | { version, savedAt, sources } | Serializable learned state for per-user persistence on your server. Set it back to restore; entries with counts only (acted, dismissed, notUseful, merged) are accepted and turned into evidence, handy to seed from a report. |
alerts | Alert[] | Current alerts (read-only copies) with placement ("inline" | "digest"), reason ("credibility" | "rate" | "trust" | null), count, first and last. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement ({placeholders} kept). English defaults. |
Methods
| Name | Description |
|---|---|
push(alert | alert[], { silent? }) | Submits alerts: { source, title, text?, severity? ("info" | "success" | "warning" | "danger"), critical?, id?, key?, sourceLabel?, actions? ([{ label, value, keep? }], up to 3), href? (http, https, mailto only), time? (Date | ms), data? }. Returns { id, verdict: "inline" | "digest" | "merged" | "pending", reason }. silent skips the live announcement (hydrating a backlog). Calls made before the element is connected are queued. |
acted(id, value?) / dismiss(id) / notUseful(id) | Teach from outside the component (the user fixed it elsewhere, a keyboard shortcut…). Each removes the alert and emits mv-feedback. |
resolve(id) / clear() | The condition cleared by itself: removes one alert (or all) without teaching anything. |
setTrust(sourceId, "always" | "auto" | "digest") | Same as the Sources panel: digest moves the source’s non-critical alerts into the digest, always moves its held alerts back inline. Emits mv-feedback with kind "trust". |
credibility(sourceId) | Current credibility (0..1), decayed to now. |
resetLearning(sourceId?) | Forgets the learned evidence, counters and record of one source or all; trust overrides are kept. |
openSources() / closeSources() | Opens or closes the Sources panel. |
Events
| Name | Description |
|---|---|
mv-alert | After every push. detail: { alert, verdict: "inline" | "digest" | "merged", reason, source (stats) }. Route inline verdicts to a toast or a push notification if you like: only what passed the guard. |
mv-demote | Cancelable, before an alert is held back (and before setTrust moves one). detail: { alert, reason: "credibility" | "rate" | "trust", source, credibility }. preventDefault() keeps it inline. |
mv-feedback | The user (or your code) taught something. detail: { kind: "acted" | "dismissed" | "not-useful" | "trust" | "reset", alert (null for trust and reset), alerts and bulk: true for Dismiss all, value (action value, "link", or the new trust), previous (credibility before, or previous trust), source (stats), credibility }. Send it to your analytics to track alert quality. |
Content structure
| Name | Description |
|---|---|
empty | Children with slot="empty" replace the default “All clear” text shown when nothing is inline. |
CSS classes
| Name | Description |
|---|---|
mv-cry-wolf-bar | Toolbar: -heading (-title, -summary “3 shown · 16 held back”) and .mv-cry-wolf-toggle (Sources button, with -pill counting held-back sources). |
mv-cry-wolf-list / mv-cry-wolf-item | Inline alerts (<ul> of <li>, data-severity, data-critical, data-tone, data-acted). Inside: .mv-cry-wolf-alert (<article>), -icon, -body, -alert-title, -flag (Critical), -text, -history (track record of a critical alert), -actions, -not-useful, -meta (-source with signal bars, <time>, -repeat), -dismiss. |
mv-cry-wolf-digest | Held-back section: -digest-head, -groups (one .mv-cry-wolf-group per source, data-open, data-flash when an alert is absorbed), -group-toggle (disclosure: -group-label with -count, -group-reason, -group-cue), -group-body, -rows (.mv-cry-wolf-row compact alerts), -group-actions. |
mv-cry-wolf-panel | Sources panel (Popover API, role dialog): -panel-head, -sources (.mv-cry-wolf-source-row, data-tone, data-routing), -meter (role meter) with -meter-fill and -meter-mark (the threshold), -trust (fieldset of three radios), -source-foot (status, stats, record), -panel-foot (Reset learning). |
mv-cry-wolf-signal / mv-cry-wolf-record | Four signal bars (data-level 1..4, colored by data-tone high | mid | low) and the reaction record: filled dot = acted on, ring = dismissed, diamond = not useful. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-cry-wolf-high | var(--mv-success) | Tone of credible sources (signal bars, meter, acted dots). |
--mv-cry-wolf-mid | var(--mv-warning) | Tone of sources between the threshold and 60%. |
--mv-cry-wolf-low | var(--mv-fg-subtle) | Tone of held-back sources: noise is muted, not alarming. |
--mv-cry-wolf-critical | var(--mv-danger) | Critical alerts: side bar, ring, Critical flag. |
--mv-cry-wolf-gap | var(--mv-space-2) | Space between inline alerts. |
Accessibility
The component is a labelled region (role="region" named by its heading). Inline alerts are a real list of <article> elements labelled by their title and described by their text and meta line, which includes the source’s credibility and the repeat count in words (“repeated 12 times in 1 h”) while the visual “×12” chip is aria-hidden. Only alerts that pass the guard are announced: a polite live region for normal ones (a burst is announced once, “3 new alerts. Latest: …”, never as a stream) and an assertive one for critical alerts; held-back alerts, merged repeats and hydrated backlogs (silent) are never announced, yet stay fully reachable: each digest group is a disclosure <button> (aria-expanded, aria-controls) whose name says how many alerts, from which source and why (“4 low-signal alerts from Disk monitor, Low credibility, latest 3 min ago”), and its rows keep their actions and a Dismiss button. Every button has an explicit name (“Dismiss: <title>”, “Not useful: <title>”); after a dismissal, focus moves to the next alert (or the previous one, then the digest, then the Sources button), never to the top of the page. When a user’s feedback flips a source in or out of the digest, it is announced politely. The Sources button has aria-haspopup="dialog" and aria-expanded; the panel is a labelled non-modal dialog placed right after the toolbar in the DOM, closed by Escape (focus returns to the button), outside click or its Close button. Each source’s credibility is a role="meter" with a value text (“18%, held in the digest”), each override is a native radio group in a fieldset with a legend (“Delivery for Disk monitor”), operable with arrow keys, and the reaction record has a text equivalent. Colors never carry meaning alone: signal bars also change length, record marks differ by shape, critical alerts carry a “Critical” flag and a side bar. Forced colors: bars, meter and marks use system colors. Reduced motion (OS or data-motion="reduce"): alerts appear and leave without height or slide animations, counters do not pop, the meter jumps.