ModsCode / Gallery / Designs / Incident board
Incident board
Incidents by severity with owner, age and a 24-hour status strip per row; the mean time to resolve sits top right, worked out from the resolved rows.


hooks/designs/incident-board.mjs
// incident-board: the last 24 hours of incidents for a small team, one row each: a severity chip, the
// title, the owner, how long it has been open (or how long it took to resolve), and a strip of 24
// hourly cells for the affected service. A quiet hour is a track at 14% ink; an hour inside the
// incident is candle's 55% body, red for the SEV1 outage and ink for degraded service, so the red reads
// as the one outage and the ink as the rest. Chips: SEV1 filled red, SEV2 an Claude orange outline (the one
// Claude orange item, so there is exactly one SEV2), SEV3 an ink outline. Open rows come first by severity,
// then resolved ones, newest first, with their titles muted and a green check by the time they took. The MTTR top right is the mean of the
// three resolved durations (1h 12m, 54m, 2h 40m, so 1h 35m), worked out from the rows so it cannot
// disagree with them. Sample data only.
const W = 380
const H = 226
const STAGE = '#FAF9F5'
const INK = '#141413'
const MUTED = '#73726C'
const UP = '#2C7A39'
const DOWN = '#AB2B3F'
const CLAUDE = '#D77757'
const FONT = "'Inter Tight','Segoe UI',system-ui,sans-serif"
const f = n => n.toFixed(1)
const ROW0 = 50
const PITCH = 26
const STRIP = { x: 224, pitch: 6, cell: 5, h: 10 } // 24 cells end at x = 368
const COL_END = 216 // the right edge of the title column
// Times in minutes. opened: how long ago it started; took: how long it ran before it was resolved.
const INCIDENTS = [
{ sev: 1, title: 'API 5xx on /v1/messages', owner: 'haru', opened: 85 },
{ sev: 2, title: 'Checkout p95 over 2s', owner: 'kenji', opened: 190 },
{ sev: 3, title: 'Flaky e2e on main', owner: 'agent-2', opened: 365 },
{ sev: 3, title: 'Webhook retries piling up', owner: 'mika', opened: 560, took: 72 },
{ sev: 3, title: 'Docs site serving stale cache', owner: 'sam', opened: 880, took: 54 },
{ sev: 3, title: 'CI runners out of disk', owner: 'lee', opened: 1260, took: 160 },
]
const text = (x, y, s, { size = 9, fill = MUTED, anchor = 'start' } = {}) =>
`<text x="${f(x)}" y="${f(y)}"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`} font-size="${size}" font-family="${FONT}" fill="${fill}">${s}</text>`
const dur = m => (m >= 60 ? `${Math.floor(m / 60)}h ${String(Math.round(m % 60)).padStart(2, '0')}m` : `${Math.round(m)}m`)
function chip(sev, x, y) {
const box = `x="${f(x + 0.5)}" y="${f(y + 0.5)}" width="31" height="12" rx="2"`
const label = text(x + 16, y + 9.5, `SEV${sev}`, { fill: sev === 1 ? INK : sev === 2 ? CLAUDE : MUTED, anchor: 'middle' })
if (sev === 1) return `<rect ${box} fill="${DOWN}" fill-opacity="0.55"/>${label}`
if (sev === 2) return `<rect ${box} fill="none" stroke="${CLAUDE}"/>${label}`
return `<rect ${box} fill="none" stroke="${INK}" stroke-opacity="0.35"/>${label}`
}
// Cell c covers the hour from (24 - c) hours ago to (23 - c) hours ago; it is hit when that hour
// overlaps the incident, from `opened` minutes ago until it was resolved (or until now)
function strip(inc, y) {
const from = inc.opened
const to = inc.took === undefined ? 0 : inc.opened - inc.took
let out = ''
for (let c = 0; c < 24; c++) {
const a = (24 - c) * 60
const b = (23 - c) * 60
const hit = a > to && b < from
const fill = hit ? `fill="${inc.sev === 1 ? DOWN : INK}" fill-opacity="0.55"` : `fill="${INK}" fill-opacity="0.14"`
out += `<rect x="${f(STRIP.x + c * STRIP.pitch)}" y="${f(y)}" width="${STRIP.cell}" height="${STRIP.h}" ${fill}/>`
}
return out
}
function draw() {
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
const open = INCIDENTS.filter(i => i.took === undefined)
const done = INCIDENTS.filter(i => i.took !== undefined)
const mttr = done.reduce((s, i) => s + i.took, 0) / done.length
// Header: open count on the left, MTTR on the right as the hero figure
parts.push(text(12, 24, String(open.length), { size: 14, fill: INK }))
parts.push(text(12 + String(open.length).length * 9 + 3, 24, `OPEN, ${INCIDENTS.length} IN 24H`))
parts.push(text(W - 12, 24, dur(mttr), { size: 14, fill: INK, anchor: 'end' }))
parts.push(text(W - 66, 24, 'MTTR', { anchor: 'end' }))
// The strip's scale, once, over the column
const sx1 = STRIP.x + 23 * STRIP.pitch + STRIP.cell
parts.push(text(STRIP.x, ROW0 - 6, '24H AGO'))
parts.push(text((STRIP.x + sx1) / 2, ROW0 - 6, '12H', { anchor: 'middle' }))
parts.push(text(sx1, ROW0 - 6, 'NOW', { anchor: 'end' }))
INCIDENTS.forEach((inc, i) => {
const top = ROW0 + i * PITCH
const resolved = inc.took !== undefined
parts.push(chip(inc.sev, 12, top + 2))
parts.push(text(52, top + 12, inc.title, { size: 10, fill: resolved ? MUTED : INK }))
parts.push(text(52, top + 23, inc.owner))
const state = resolved ? `RESOLVED IN ${dur(inc.took)}` : `OPEN ${dur(inc.opened)}`
parts.push(text(COL_END, top + 23, state, { anchor: 'end' }))
// A resolved row gets ci-pipeline's check, drawn as a path, left of its words: three green texts
// outweighed the open SEV1. The words are placed at 4.8px a character (measured 4.7), so a wider fallback font still clears it.
if (resolved) {
const gx = COL_END - state.length * 4.8 - 8
const gy = top + 20
parts.push(`<path d="M${f(gx - 2.6)} ${f(gy + 0.2)}L${f(gx - 0.8)} ${f(gy + 2)}L${f(gx + 2.7)} ${f(gy - 1.9)}" fill="none" stroke="${UP}"/>`)
}
parts.push(strip(inc, top + 7))
})
parts.push(text(12, H - 8, 'SAMPLE INCIDENTS'))
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join('')}</svg>`
}
let source // drawn on first use and kept: the pane loads every design, so none pays at load time
export default {
id: 'incident-board',
title: 'Incident board',
category: 'work',
description: 'Incidents by severity with owner, age and a 24-hour status strip per row; the mean time to resolve sits top right, worked out from the resolved rows.',
element: ui => ui.Svg({
source: (source ??= draw()),
alt: `Incident board of ${INCIDENTS.length} incidents with severity chips, owners, ages and 24-hour status strips, and the mean time to resolve, sample data`,
width: W,
height: H,
}),
}
To use it in a mod: copy the file to your mod's hooks/designs/ and return its element(ui) from a pane's ui.render. How a pane is built.
Code: PolyForm Noncommercial License 1.0.0: noncommercial use only. Required Notice: Copyright 2026 Haruki Nakada (https://modscode.com).