Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee8583f6f | |||
| 591d5b8df0 | |||
| d2cdcc9776 | |||
| 8b2789b9b2 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.22.0
|
version: 0.23.0
|
||||||
appVersion: "v0.22.0"
|
appVersion: "v0.23.0"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for file, want := range map[string]string{
|
||||||
|
"js/incident.js": "copyIncident",
|
||||||
|
"js/ui.js": "copy:",
|
||||||
|
} {
|
||||||
|
b, err := fs.ReadFile(sub, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), want) {
|
||||||
|
t.Errorf("%s lacks %s", file, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -377,6 +377,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.detail-head .copy { margin-left: auto; }
|
||||||
|
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||||
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||||
@@ -638,6 +640,10 @@ kbd {
|
|||||||
.view-queue .pane { overflow: auto; height: 100dvh; }
|
.view-queue .pane { overflow: auto; height: 100dvh; }
|
||||||
.pane-list { border-right: 1px solid var(--border); }
|
.pane-list { border-right: 1px solid var(--border); }
|
||||||
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
||||||
|
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||||
|
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||||
|
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||||
|
.pane-list .chip-sep { display: none; }
|
||||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||||
|
|
||||||
/* On desktop the list stays visible next to the detail. */
|
/* On desktop the list stays visible next to the detail. */
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ function render() {
|
|||||||
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
||||||
icon('back')),
|
icon('back')),
|
||||||
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
||||||
|
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
|
||||||
|
icon('copy')),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!inc) {
|
if (!inc) {
|
||||||
@@ -186,8 +188,9 @@ function alertItem(a) {
|
|||||||
|
|
||||||
// ---------- timeline ----------
|
// ---------- timeline ----------
|
||||||
|
|
||||||
function eventText(ev) {
|
// named spells users out instead of "you", for text that leaves this page.
|
||||||
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
function eventText(ev, named = false) {
|
||||||
|
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
|
||||||
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
||||||
const alertName = () => {
|
const alertName = () => {
|
||||||
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
||||||
@@ -263,6 +266,100 @@ function timelineItem(ev) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- copy ----------
|
||||||
|
|
||||||
|
const fence = (rows) => ['```', ...rows, '```'];
|
||||||
|
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
|
||||||
|
|
||||||
|
// incidentMarkdown is everything on this page as text that reads well in a chat
|
||||||
|
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
|
||||||
|
// it has been pasted somewhere else.
|
||||||
|
function incidentMarkdown() {
|
||||||
|
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
|
||||||
|
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
|
||||||
|
add('Status', inc.status);
|
||||||
|
add('Severity', inc.severity);
|
||||||
|
add('Team', inc.team_name);
|
||||||
|
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
|
||||||
|
add('Triggered', inc.triggered_at);
|
||||||
|
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
|
||||||
|
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
|
||||||
|
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
|
||||||
|
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
|
||||||
|
if (inc.archived_at) add('Archived', inc.archived_at);
|
||||||
|
const group = pairs(inc.group_labels);
|
||||||
|
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
|
||||||
|
|
||||||
|
const alerts = inc.alerts || [];
|
||||||
|
out.push('', `## Alerts (${alerts.length})`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
out.push('', `### ${a.name} (${a.status})`);
|
||||||
|
out.push(`- Started: ${a.starts_at}`);
|
||||||
|
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
|
||||||
|
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
|
||||||
|
const labels = pairs(a.labels);
|
||||||
|
if (labels.length) out.push('', 'Labels:', ...fence(labels));
|
||||||
|
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
|
||||||
|
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||||
|
if (sorted.length) {
|
||||||
|
out.push('', '## Timeline', '');
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
|
||||||
|
out.push(`- ${ev.created_at} ${text}`);
|
||||||
|
if (isNote(ev) && ev.detail) {
|
||||||
|
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
|
||||||
|
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarList.length) {
|
||||||
|
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
|
||||||
|
for (const s of similarList) {
|
||||||
|
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
|
||||||
|
for (const n of s.resolution_notes || []) {
|
||||||
|
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeClipboard falls back to execCommand: the async API needs a secure
|
||||||
|
// context, and this server is often reached over plain HTTP.
|
||||||
|
async function writeClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
|
||||||
|
ta.value = text;
|
||||||
|
document.body.append(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
if (!document.execCommand('copy')) throw new Error('copy refused');
|
||||||
|
} finally {
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIncident() {
|
||||||
|
if (!inc) return;
|
||||||
|
try {
|
||||||
|
await writeClipboard(incidentMarkdown());
|
||||||
|
toast('Copied incident');
|
||||||
|
} catch {
|
||||||
|
toast('Could not copy', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- actions ----------
|
// ---------- actions ----------
|
||||||
|
|
||||||
const isOpen = () => inc.status !== 'resolved';
|
const isOpen = () => inc.status !== 'resolved';
|
||||||
@@ -470,10 +567,12 @@ async function moreMenu() {
|
|||||||
items.push(item('user', 'Assign…', assign));
|
items.push(item('user', 'Assign…', assign));
|
||||||
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
||||||
items.push(item('note', 'Add note…', addNote));
|
items.push(item('note', 'Add note…', addNote));
|
||||||
|
items.push(item('copy', 'Copy incident', copyIncident));
|
||||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||||
} else {
|
} else {
|
||||||
items.push(item('note', 'Add note…', addNote));
|
items.push(item('note', 'Add note…', addNote));
|
||||||
|
items.push(item('copy', 'Copy incident', copyIncident));
|
||||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +601,7 @@ export function key(e) {
|
|||||||
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
||||||
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
||||||
case 'c': addNote(); return true;
|
case 'c': addNote(); return true;
|
||||||
|
case 'y': copyIncident(); return true;
|
||||||
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const ICONS = {
|
|||||||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||||||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||||||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||||||
|
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
|
||||||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||||
chevronRight: ['M9 6l6 6-6 6'],
|
chevronRight: ['M9 6l6 6-6 6'],
|
||||||
|
|||||||
Reference in New Issue
Block a user