Skip to content

Commit 43c1f7c

Browse files
committed
feat: add copyable bug report generation on errors
When errors occur, a structured anonymous bug report is automatically attached to the error message. Users can copy it to clipboard from the notification center. The report includes build info (volview version, git SHA, vtk.js/itk-wasm versions), browser/OS, error stack trace, and dataset metadata (dimensions, scalar type, source format, segment groups) without exposing any patient data. Closes #854
1 parent 2132fb4 commit 43c1f7c

6 files changed

Lines changed: 226 additions & 16 deletions

File tree

src/components/MessageItem.vue

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { computed, defineComponent, PropType, toRefs } from 'vue';
3+
import { useClipboard } from '@vueuse/core';
34
import { Message, MessageType } from '../store/messages';
45
56
const MessageTypeClass: Record<MessageType, string> = {
@@ -18,6 +19,7 @@ export default defineComponent({
1819
},
1920
setup(props) {
2021
const { message } = toRefs(props);
22+
const { copy, copied } = useClipboard();
2123
2224
const headerClass = computed(() => {
2325
const type = MessageTypeClass[message.value.type];
@@ -27,8 +29,15 @@ export default defineComponent({
2729
return '';
2830
});
2931
32+
const copyBugReport = () => {
33+
const report = message.value.options.bugReport;
34+
if (report) copy(report);
35+
};
36+
3037
return {
3138
headerClass,
39+
copied,
40+
copyBugReport,
3241
};
3342
},
3443
});
@@ -39,13 +48,25 @@ export default defineComponent({
3948
<v-expansion-panel-title :class="headerClass">
4049
<div class="header">
4150
<span>{{ message.title }}</span>
42-
<v-btn
43-
icon="mdi-delete"
44-
variant="text"
45-
size="small"
46-
class="mr-3"
47-
@click.stop="$emit('delete')"
48-
/>
51+
<div>
52+
<v-btn
53+
v-if="message.options.bugReport"
54+
:prepend-icon="copied ? 'mdi-check' : 'mdi-content-copy'"
55+
variant="tonal"
56+
size="small"
57+
data-testid="copy-bug-report-button"
58+
@click.stop="copyBugReport"
59+
>
60+
Copy Bug Report
61+
</v-btn>
62+
<v-btn
63+
icon="mdi-delete"
64+
variant="text"
65+
size="small"
66+
class="mr-3"
67+
@click.stop="$emit('delete')"
68+
/>
69+
</div>
4970
</div>
5071
</v-expansion-panel-title>
5172
<v-expansion-panel-text v-if="message.options.details">

src/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ interface ImportMeta {
1212
}
1313

1414
declare const __VERSIONS__: Record<string, string>;
15+
declare const __GIT_SHORT_SHA__: string;

src/store/messages.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineStore } from 'pinia';
22
import { removeFromArray } from '../utils';
3+
import { generateBugReport } from '../utils/bugReport';
34

45
export enum MessageType {
56
Error,
@@ -10,6 +11,7 @@ export enum MessageType {
1011

1112
export type MessageOptions = {
1213
details?: string;
14+
bugReport?: string;
1315
persist?: boolean;
1416
};
1517

@@ -22,6 +24,7 @@ export interface Message {
2224

2325
export type UpdateProgressFunction = (progress: number) => void;
2426
export type TaskFunction = (updateProgress?: UpdateProgressFunction) => any;
27+
2528
interface State {
2629
_nextID: number;
2730
byID: Record<string, Message>;
@@ -52,25 +55,31 @@ export const useMessageStore = defineStore('message', {
5255
addError(title: string, opts?: Error | string | MessageOptions) {
5356
console.error(title, opts);
5457

58+
let id: string;
5559
if (opts instanceof Error) {
56-
return this._addMessage(
60+
id = this._addMessage(
5761
{
5862
type: MessageType.Error,
5963
title,
6064
},
6165
{
62-
details: String(opts),
66+
details: opts.stack ?? String(opts),
6367
persist: false,
6468
}
6569
);
70+
} else {
71+
id = this._addMessage(
72+
{
73+
type: MessageType.Error,
74+
title,
75+
},
76+
opts
77+
);
6678
}
67-
return this._addMessage(
68-
{
69-
type: MessageType.Error,
70-
title,
71-
},
72-
opts
73-
);
79+
80+
this.byID[id].options.bugReport = generateBugReport(opts);
81+
82+
return id;
7483
},
7584
/**
7685
* Adds a warning message.

src/utils/bugReport.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* global __VERSIONS__, __GIT_SHORT_SHA__ */
2+
3+
import { useDatasetStore } from '@/src/store/datasets';
4+
import { useDICOMStore } from '@/src/store/datasets-dicom';
5+
import { useImageCacheStore } from '@/src/store/image-cache';
6+
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
7+
8+
const MAX_ERROR_LENGTH = 4000;
9+
10+
const COMPOUND_EXTENSIONS = ['nii.gz', 'iwi.cbor', 'seg.nrrd'];
11+
12+
const parseBrowserInfo = (): string => {
13+
if (typeof navigator === 'undefined') return 'unknown';
14+
15+
const { userAgent: ua } = navigator;
16+
17+
const patterns: [string, RegExp][] = [
18+
['Firefox', /Firefox\/([\d.]+)/],
19+
['Edge', /Edg\/([\d.]+)/],
20+
['Chrome', /Chrome\/([\d.]+)/],
21+
['Safari', /Version\/([\d.]+).*Safari/],
22+
];
23+
24+
const match = patterns
25+
.map(([name, re]) => {
26+
const m = ua.match(re);
27+
return m ? `${name} ${m[1]}` : null;
28+
})
29+
.find(Boolean);
30+
31+
const browser = match ?? 'Unknown';
32+
33+
const os = ['Windows', 'Mac', 'Linux', 'Android', 'iPhone', 'iPad'].find(
34+
(name) => ua.includes(name)
35+
);
36+
37+
const osLabel =
38+
os === 'Mac'
39+
? 'macos'
40+
: os === 'iPhone' || os === 'iPad'
41+
? 'ios'
42+
: (os?.toLowerCase() ?? 'unknown');
43+
44+
return `${browser} (${osLabel})`;
45+
};
46+
47+
const getSourceFormat = (name: string, isDicom: boolean): string => {
48+
if (isDicom) return 'DICOM';
49+
const lower = name.toLowerCase();
50+
const compound = COMPOUND_EXTENSIONS.find((ext) => lower.endsWith(`.${ext}`));
51+
if (compound) return compound;
52+
const lastDot = name.lastIndexOf('.');
53+
return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : 'unknown';
54+
};
55+
56+
const formatScalarType = (constructorName: string): string =>
57+
constructorName.replace('Array', '');
58+
59+
const formatError = (error: unknown): string => {
60+
if (!error) return 'No error details available';
61+
const text =
62+
error instanceof Error ? (error.stack ?? String(error)) : String(error);
63+
return text.length > MAX_ERROR_LENGTH
64+
? `${text.slice(0, MAX_ERROR_LENGTH)}\n... (truncated)`
65+
: text;
66+
};
67+
68+
const collectDatasetInfo = (): string[] => {
69+
const datasetStore = useDatasetStore();
70+
const imageCacheStore = useImageCacheStore();
71+
const dicomStore = useDICOMStore();
72+
const segmentGroupStore = useSegmentGroupStore();
73+
74+
return datasetStore.idsAsSelections.map((id, i) => {
75+
const metadata = imageCacheStore.getImageMetadata(id);
76+
const imageData = imageCacheStore.getVtkImageData(id);
77+
78+
const dims = metadata
79+
? Array.from(metadata.dimensions).join('\u00d7')
80+
: 'unknown';
81+
82+
const scalars = imageData?.getPointData().getScalars()?.getData();
83+
const dataType = scalars
84+
? formatScalarType(scalars.constructor.name)
85+
: 'unknown';
86+
87+
const isDicom = id in dicomStore.volumeInfo;
88+
const sourceFormat = metadata
89+
? getSourceFormat(metadata.name, isDicom)
90+
: isDicom
91+
? 'DICOM'
92+
: 'unknown';
93+
94+
const segCount = segmentGroupStore.orderByParent[id]?.length ?? 0;
95+
const segPart = segCount > 0 ? ` (segment groups: ${segCount})` : '';
96+
97+
return ` [${i}] ${dims} ${dataType} from ${sourceFormat}${segPart}`;
98+
});
99+
};
100+
101+
export const generateBugReport = (error: unknown): string => {
102+
const versions = __VERSIONS__;
103+
const sha = __GIT_SHORT_SHA__;
104+
105+
const lines = [
106+
'--- VolView Bug Report ---',
107+
`Build: volview ${versions.volview} (${sha}) | vtk.js: ${versions['vtk.js']}, itk-wasm: ${versions['itk-wasm']}`,
108+
`Browser: ${parseBrowserInfo()}`,
109+
'',
110+
'Error:',
111+
formatError(error),
112+
];
113+
114+
try {
115+
const datasets = collectDatasetInfo();
116+
const segmentGroupStore = useSegmentGroupStore();
117+
118+
lines.push('', `Datasets: ${datasets.length}`);
119+
lines.push(...datasets);
120+
lines.push(`Save format: ${segmentGroupStore.saveFormat}`);
121+
} catch {
122+
lines.push('', 'Datasets: unavailable');
123+
}
124+
125+
lines.push('--- End Report ---');
126+
127+
return lines.join('\n');
128+
};

tests/specs/bug-report.e2e.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { volViewPage } from '../pageobjects/volview.page';
2+
import { writeManifestToFile } from './utils';
3+
4+
describe('Bug report generation', () => {
5+
it('should show Copy Bug Report button on error with stack trace details', async () => {
6+
// Trigger an error by loading a malformed URL
7+
const manifest = { resources: [{ url: 'bad-url-to-trigger-error' }] };
8+
await writeManifestToFile(manifest, 'bugReportTest.json');
9+
await volViewPage.open('?urls=[tmp/bugReportTest.json]');
10+
11+
await volViewPage.waitForNotification();
12+
13+
// Click the notifications badge to open the message center
14+
const notificationBadge = volViewPage.notifications;
15+
await notificationBadge.click();
16+
17+
// Wait for the message center dialog and the Copy Bug Report button in the title
18+
await browser.waitUntil(
19+
async () => {
20+
const btn = await $('[data-testid="copy-bug-report-button"]');
21+
return btn.isDisplayed();
22+
},
23+
{ timeout: 5000, timeoutMsg: 'Expected Copy Bug Report button' }
24+
);
25+
26+
// Expand the error message to reveal details
27+
const panelTitle = await $('.v-expansion-panel-title');
28+
await panelTitle.click();
29+
30+
// Verify the error details with stack trace are shown
31+
await browser.waitUntil(
32+
async () => {
33+
const details = await $('.details');
34+
if (!(await details.isExisting())) return false;
35+
const text = await details.getText();
36+
return text.length > 0;
37+
},
38+
{ timeout: 3000, timeoutMsg: 'Expected error details with stack trace' }
39+
);
40+
});
41+
});

vite.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference types="vitest" />
22
import * as path from 'node:path';
33
import * as fs from 'node:fs';
4+
import { execSync } from 'node:child_process';
45
import { createRequire } from 'node:module';
56
import { Plugin, defineConfig, normalizePath } from 'vite';
67
import vue from '@vitejs/plugin-vue';
@@ -54,6 +55,14 @@ function getPackageInfo() {
5455
};
5556
}
5657

58+
function getGitShortSha() {
59+
try {
60+
return execSync('git rev-parse --short HEAD').toString().trim();
61+
} catch {
62+
return 'unknown';
63+
}
64+
}
65+
5766
const rootDir = resolvePath(__dirname);
5867
const distDir = resolvePath(rootDir, 'dist');
5968

@@ -101,6 +110,7 @@ export default defineConfig({
101110
'vtk.js': pkgInfo.versions['vtk.js'],
102111
'itk-wasm': pkgInfo.versions['itk-wasm'],
103112
},
113+
__GIT_SHORT_SHA__: JSON.stringify(getGitShortSha()),
104114
},
105115
resolve: {
106116
alias: [

0 commit comments

Comments
 (0)