Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.so",
"version": "1.3.1",
"version": "1.4.0",
"private": true,
"packageManager": "yarn@4.18.0",
"scripts": {
Expand Down
13 changes: 13 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ type Event {
"""Release data"""
release: Release

"""Release of the original event, regardless of the selected repetition"""
firstSeenInRelease: String

"""Whether the event first appeared in the latest valid registered project release"""
isNew: Boolean!

"""Release in which the event was resolved"""
resolvedInRelease: String

"""Release in which the event regressed"""
regressionInRelease: String

"""Event repetitions portion"""
repetitionsPortion(cursor: String = null, limit: Int = 10): RepetitionsPortion!

Expand Down Expand Up @@ -1095,6 +1107,7 @@ type Project {
"""User who created project"""
uidAdded: User!


"""Project's Event"""
event(eventId: ID!, originalEventId: ID!): Event

Expand Down
2 changes: 1 addition & 1 deletion src/api/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const getEvent = withDemoMock(
originalEventId,
})).project;

if (!project) {
if (!project?.event) {
return null;
}

Expand Down
4 changes: 4 additions & 0 deletions src/api/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export const EVENT_FRAGMENT = `
timestamp
originalTimestamp
originalEventId
firstSeenInRelease
isNew
resolvedInRelease
regressionInRelease
payload {
title
type
Expand Down
13 changes: 7 additions & 6 deletions src/api/projects/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,14 @@ export const fetchChartData = withDemoMock(
*/
export const fetchProjectReleases = withDemoMock(
async function fetchProjectReleases(projectId) {
const response = await api.call(QUERY_PROJECT_RELEASES, { projectId });

if (response.errors?.length) {
response.errors.forEach(console.error);
}
const response = await api.call(QUERY_PROJECT_RELEASES, { projectId }, undefined, {
allowErrors: true,
});

return response.data.project.releases;
/*
* GraphQL may return usable release data alongside field-level errors.
*/
return response.data?.project?.releases ?? [];
},
'/src/api/projects/mocks/fetchProjectReleases.mock.ts'
);
Expand Down
23 changes: 22 additions & 1 deletion src/components/event/EventHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,14 @@
</div>

<div class="event-header__type">
{{ !loading ? event.payload.type || 'Application error' : $t('event.loading') }}
<span class="event-header__type-label">
{{ !loading ? event.payload.type || 'Application error' : $t('event.loading') }}
</span>
<EventReleaseStatus
v-if="!loading"
:event="event"

/>
</div>

<h1 class="event-header__title">
Expand Down Expand Up @@ -150,6 +157,7 @@ import EntityImage from '../utils/EntityImage.vue';

import { HawkEvent, HawkEventBacktraceFrame } from '@/types/events';
import { TOGGLE_EVENT_MARK } from '@/store/modules/events/actionTypes';

import { Project } from '@/types/project';
import { Workspace } from '@/types/workspaces';
import { projectBadges } from '../../mixins/projectBadges';
Expand All @@ -158,6 +166,7 @@ import { JavaScriptAddons } from '@hawk.so/types';
import { Button as CdxButton, usePopover } from '@codexteam/ui/vue';
import EventActionsMenu from './EventActionsMenu.vue';
import AiSuggestionDialog from '../modals/AiSuggestionDialog.vue';
import EventReleaseStatus from './EventReleaseStatus.vue';

export default defineComponent({
name: 'EventHeader',
Expand All @@ -171,6 +180,7 @@ export default defineComponent({
EntityImage,
ProjectBadge,
AiSuggestionDialog,
EventReleaseStatus,
},
mixins: [projectBadges],
props: {
Expand Down Expand Up @@ -202,7 +212,9 @@ export default defineComponent({
isAiSuggestionOpen: false as boolean,
};
},

computed: {

/**
* Event location got from the first backtrace frame
* Or got from a url if the backtrace is empty
Expand Down Expand Up @@ -412,8 +424,17 @@ export default defineComponent({
}

&__type {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
font-weight: 500;
font-size: 12.2px;
}

&__type-label {
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--font-monospace);
letter-spacing: 0.21px;
}
Expand Down
139 changes: 139 additions & 0 deletions src/components/event/EventReleaseStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createApp, h, nextTick, reactive, type App } from 'vue';
import { createI18n } from 'vue-i18n';
import en from '../../i18n/messages/en.json';
import ru from '../../i18n/messages/ru.json';
import type { HawkEvent } from '../../types/events';
import EventReleaseStatus from './EventReleaseStatus.vue';

type ReleaseState = Pick<HawkEvent, 'firstSeenInRelease' | 'resolvedInRelease' | 'regressionInRelease' | 'isNew'>;

let app: App | undefined;
let host: HTMLDivElement;

afterEach(() => {
app?.unmount();
host.remove();
});

/**
* Mount the badge with the real translations and reactive event state.
* @param event - lifecycle fields from the event
* @param locale - UI language
* @returns badge container
*/
function mount(event: ReleaseState, locale = 'en'): HTMLDivElement {
host = document.createElement('div');
document.body.append(host);
app = createApp({ render: () => h(EventReleaseStatus, { event }) });
app.use(createI18n({ legacy: false,
locale,
messages: { en,
ru } }));
app.mount(host);

return host;
}

describe('EventReleaseStatus', () => {
it.each([
{},
{ firstSeenInRelease: null,
resolvedInRelease: null,
regressionInRelease: null },
])('hides the badge without lifecycle data: %j', (event) => {
expect(mount(event).querySelector('.event-release-status')).toBeNull();
expect(host.textContent).toBe('');
});

it.each([undefined, null, '', ' ', 'null', 'undefined', ' NULL ', ' Undefined ', 'NaN'])(
'hides non-meaningful release values: %j', (release) => {
expect(mount({ firstSeenInRelease: release,
resolvedInRelease: release,
regressionInRelease: release,
isNew: true }).querySelector('.event-release-status')).toBeNull();
}
);

it('falls back to valid metadata when higher-priority fields are placeholders', () => {
mount({ firstSeenInRelease: 'original',
resolvedInRelease: 'fixed',
regressionInRelease: 'null' });
expect(host.textContent?.trim()).toBe('Resolved');
});

it.each([
['en', 'firstSeen', { firstSeenInRelease: 'build/start#1' }, 'New',
'The event first appeared in release build/start#1, the project\'s latest release.'],
['ru', 'firstSeen', { firstSeenInRelease: 'build/start#1' }, 'Новое',
'Событие впервые появилось в релизе build/start#1. Это последний релиз проекта.'],
['en', 'resolved', { firstSeenInRelease: 'z-original',
resolvedInRelease: 'a-fixed' }, 'Resolved',
'No occurrences observed starting with release a-fixed. This is an automatic estimate: the event may be fixed.'],
['ru', 'resolved', { firstSeenInRelease: 'z-original',
resolvedInRelease: 'a-fixed' }, 'Исправлено',
'Не наблюдалось начиная с релиза a-fixed. Это автоматическая оценка: событие потенциально исправлено.'],
['en', 'regression', { firstSeenInRelease: 'z-original',
resolvedInRelease: 'z-fixed',
regressionInRelease: 'a/again#2' }, 'Regression',
'The event reappeared in release a/again#2 after it was considered fixed.'],
['ru', 'regression', { firstSeenInRelease: 'z-original',
resolvedInRelease: 'z-fixed',
regressionInRelease: 'a/again#2' }, 'Регрессия',
'Событие вернулось в релизе a/again#2 после предполагаемого исправления.'],
] as const)('renders %s %s with the server flag and lifecycle priority', (locale, status, event, label, tooltip) => {
const badge = mount({ ...event,
isNew: true }, locale).querySelector('.event-release-status');

expect(badge?.textContent?.trim()).toBe(label);
expect(badge?.getAttribute('title')).toBe(tooltip);
expect(badge?.classList.contains(`event-release-status--${status}`)).toBe(true);
expect(host.querySelectorAll('.event-release-status')).toHaveLength(1);
for (const release of Object.values(event)) {
expect(badge?.textContent).not.toContain(release);
}
});

it.each([undefined, false])(
'hides the new badge when isNew is unavailable or false: %j', (isNew) => {
mount({ firstSeenInRelease: 'original',
isNew });
expect(host.querySelector('.event-release-status')).toBeNull();
}
);

it('removes the new badge when the server flag changes', async () => {
const event = reactive<ReleaseState>({ firstSeenInRelease: 'current',
isNew: true });

mount(event);
expect(host.textContent?.trim()).toBe('New');

event.isNew = false;
await nextTick();
expect(host.querySelector('.event-release-status')).toBeNull();
});

it('returns to resolved when a regression is cleared reactively', async () => {
const event = reactive<ReleaseState>({
firstSeenInRelease: 'original',
resolvedInRelease: 'z-fixed',
regressionInRelease: 'a-regression',
});

mount(event);
expect(host.textContent?.trim()).toBe('Regression');

event.regressionInRelease = null;
await nextTick();

const badge = host.querySelector('.event-release-status');

expect(badge?.textContent?.trim()).toBe('Resolved');
expect(badge?.getAttribute('title')).toBe(
'No occurrences observed starting with release z-fixed. This is an automatic estimate: the event may be fixed.'
);
expect(badge?.classList.contains('event-release-status--resolved')).toBe(true);
expect(badge?.classList.contains('event-release-status--regression')).toBe(false);
});
});
97 changes: 97 additions & 0 deletions src/components/event/EventReleaseStatus.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<template>
<span
v-if="status"
class="event-release-status"
:class="`event-release-status--${status}`"
:title="tooltip"
>
{{ $t(`event.releaseStatus.${status}`) }}
</span>
</template>

<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { HawkEvent } from '@/types/events';
import { isMeaningfulRelease } from '@/utils';

type EventReleaseState = Pick<HawkEvent, 'firstSeenInRelease' | 'resolvedInRelease' | 'regressionInRelease' | 'isNew'>;

export default defineComponent({
name: 'EventReleaseStatus',
props: {

event: {
type: Object as PropType<EventReleaseState>,
required: true,
},
},
computed: {
/**
* A regression supersedes the previous resolution; release names are opaque.
*/
status(): 'firstSeen' | 'resolved' | 'regression' | null {
if (isMeaningfulRelease(this.event.regressionInRelease)) {
return 'regression';
}

if (isMeaningfulRelease(this.event.resolvedInRelease)) {
return 'resolved';
}

if (this.event.isNew === true && isMeaningfulRelease(this.event.firstSeenInRelease)) {
return 'firstSeen';
}

return null;
},
/**
* Explain the status and its release without expanding the visible badge.
*/
tooltip(): string {
switch (this.status) {
case 'firstSeen':
return this.$t('event.releaseStatusTooltip.firstSeen', { release: this.event.firstSeenInRelease });
case 'resolved':
return this.$t('event.releaseStatusTooltip.resolved', { release: this.event.resolvedInRelease });
case 'regression':
return this.$t('event.releaseStatusTooltip.regression', { release: this.event.regressionInRelease });
default:
return '';
}
},
},
});
</script>

<style scoped>
.event-release-status {
display: inline-block;
min-width: 0;
max-width: 100%;
padding: 2px 9px;
overflow: hidden;

font-size: 11px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: middle;
background-color: transparent;
border: 1px solid currentColor;
border-radius: 999px;
cursor: help;

&--firstSeen {
color: var(--color-text-highlighted);
}

&--resolved {
color: var(--color-indicator-positive);
}

&--regression {
color: var(--color-indicator-warning);
}
}
</style>
Loading
Loading