Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { translate } from '$lib/i18n/translate';
import type {
EventGroup,
EventGroups,
Expand All @@ -9,6 +10,7 @@
EventTypeCategory,
WorkflowEventWithPending,
} from '$lib/types/events';
import { getEventClassificationLabel } from '$lib/utilities/get-status-label';
import {
isPendingActivity,
isPendingNexusOperation,
Expand Down Expand Up @@ -69,9 +71,43 @@
: (undefined as EventTypeCategory | 'pending' | undefined),
);
const reverseSort = $derived($eventFilterSort === 'descending');

const isRetrying = $derived(
!!group?.pendingActivity && Number(group.pendingActivity.attempt) > 1,
);

const statusForLabel = $derived(
isRetrying
? 'Retrying'
: classification === 'pending'
? 'Pending'
: classification,
);

const classificationLabel = $derived(
getEventClassificationLabel(statusForLabel),
);

const eventTypeLabel = $derived(
event && 'eventType' in event
? event.eventType
: translate('common.unknown'),
);

const accessibleName = $derived(
translate('events.row-accessible-name', {
eventType: eventTypeLabel,
classification: classificationLabel,
}),
);
</script>

<g role="button" tabindex="0" class="relative cursor-pointer">
<g
role="button"
tabindex="0"
aria-label={accessibleName}
class="relative cursor-pointer"
>
{#if connectLine}
<Line
startPoint={[canvasWidth, y]}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/components/lines-and-dots/svg/timeline-graph-row.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
} from '$lib/utilities/decode-local-activity';
import { getMillisecondDuration } from '$lib/utilities/format-time';
import type { SummaryAttribute } from '$lib/utilities/get-single-attribute-for-event';
import { getEventClassificationLabel } from '$lib/utilities/get-status-label';
import {
isActivityTaskScheduledEvent,
isActivityTaskStartedEvent,
Expand Down Expand Up @@ -54,6 +55,15 @@

const timelineWidth = $derived(canvasWidth - 2 * gutter);
const pendingActivity = $derived(group?.pendingActivity);

const accessibleName = $derived(
translate('events.row-accessible-name', {
eventType: group.displayName,
classification: getEventClassificationLabel(
group.finalClassification || group.classification,
),
}),
);
const pauseTime = $derived(
pendingActivity && pendingActivity.pauseInfo?.pauseTime,
);
Expand Down Expand Up @@ -197,6 +207,7 @@
<g
role="button"
tabindex="0"
aria-label={accessibleName}
onclick={onClick}
onkeypress={onClick}
onmouseenter={onMouseEnter}
Expand Down
17 changes: 16 additions & 1 deletion src/lib/components/lines-and-dots/svg/workflow-row.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script lang="ts">
import Icon from '$lib/holocene/icon/icon.svelte';
import { translate } from '$lib/i18n/translate';
import type { WorkflowExecution } from '$lib/types/workflows';
import { isWorkflowDelayed } from '$lib/utilities/delayed-workflows';
import { getWorkflowStatusLabel } from '$lib/utilities/get-status-label';

import { TimelineConfig } from '../constants';

Expand All @@ -20,9 +22,22 @@

const start = $derived(gutter);
const end = $derived(start + length - 2 * gutter);

const accessibleName = $derived(
translate('workflows.row-accessible-name', {
workflowId: workflow.id,
status: getWorkflowStatusLabel(workflow.status),
}),
);
</script>

<g role="button" tabindex="0" class="relative cursor-pointer" {height}>
<g
role="button"
tabindex="0"
aria-label={accessibleName}
class="relative cursor-pointer"
{height}
>
<Line
startPoint={[start, y]}
endPoint={[end, y]}
Expand Down
35 changes: 2 additions & 33 deletions src/lib/components/workflow-status.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,10 @@
import Spinner from '$lib/holocene/icon/svg/spinner.svelte';
import Tooltip from '$lib/holocene/tooltip.svelte';
import { translate } from '$lib/i18n/translate';
import type { EventClassification } from '$lib/models/event-history/get-event-classification';
import type { ScheduleStatus } from '$lib/types/schedule';
import type { WorkflowStatus } from '$lib/types/workflows';
import { getStatusLabel, type Status } from '$lib/utilities/get-status-label';

import HeartBeat from './heart-beat-indicator.svelte';

type Status =
| WorkflowStatus
| ScheduleStatus
| EventClassification
| 'Pending'
| 'Retrying';

interface Props {
delay?: number;
status?: Status;
Expand All @@ -45,28 +36,6 @@
'test-id': testId,
}: Props = $props();

const label: Record<Status, string> = {
Running: translate('workflows.running'),
TimedOut: translate('workflows.timed-out'),
Completed: translate('workflows.completed'),
Failed: translate('workflows.failed'),
ContinuedAsNew: translate('workflows.continued-as-new'),
Canceled: translate('workflows.canceled'),
Terminated: translate('workflows.terminated'),
Paused: translate('workflows.paused'),
Scheduled: translate('events.event-classification.scheduled'),
Started: translate('events.event-classification.started'),
Unspecified: translate('events.event-classification.unspecified'),
Open: translate('events.event-classification.open'),
New: translate('events.event-classification.new'),
Initiated: translate('events.event-classification.initiated'),
Fired: translate('events.event-classification.fired'),
CancelRequested: translate('events.event-classification.cancelrequested'),
Signaled: translate('events.event-classification.signaled'),
Pending: translate('events.event-classification.pending'),
Retrying: translate('events.event-classification.retrying'),
};

const workflowStatus = cva(
[
'flex items-center rounded-sm px-1 py-0.5 h-5 whitespace-nowrap text-black gap-1 font-medium',
Expand Down Expand Up @@ -135,7 +104,7 @@
{count.toLocaleString()}
{/if}

{label[status]}
{getStatusLabel(status)}
{#if status === 'Running' && !delayed && !taskFailure}
<HeartBeat {delay} />
{/if}
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/locales/en/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const Strings = {
'event-history-import-error': 'Could not create event history from JSON',
'event-history-load-error': 'Could not parse JSON',
'event-classification-label': 'Event Classification',
'row-accessible-name': 'Event {{eventType}}: {{classification}}',
'event-classification': {
unspecified: 'Unspecified',
scheduled: 'Scheduled',
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/locales/en/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const Strings = {
'configure-headers-description':
'Add (<1></1>), re-arrange (<2></2>), and remove (<3></3>), {{type}} to personalize the {{title}} Table.',
'all-statuses': 'All Statuses',
'row-accessible-name': 'Workflow {{workflowId}}: {{status}}',
running: 'Running',
'timed-out': 'Timed Out',
completed: 'Completed',
Expand Down
59 changes: 59 additions & 0 deletions src/lib/utilities/get-status-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';

import {
getEventClassificationLabel,
getStatusLabel,
getWorkflowStatusLabel,
type Status,
} from './get-status-label';

describe('getWorkflowStatusLabel', () => {
it('translates workflow / schedule statuses', () => {
expect(getWorkflowStatusLabel('Running')).toBe('Running');
expect(getWorkflowStatusLabel('TimedOut')).toBe('Timed Out');
expect(getWorkflowStatusLabel('ContinuedAsNew')).toBe('Continued as New');
expect(getWorkflowStatusLabel('Paused')).toBe('Paused');
});

it('falls back to "Unknown" for null / undefined / event-only names', () => {
expect(getWorkflowStatusLabel(undefined)).toBe('Unknown');
expect(getWorkflowStatusLabel(null)).toBe('Unknown');
// Event-only classification is not part of the workflow domain.
expect(getWorkflowStatusLabel('Signaled' as never)).toBe('Unknown');
});
});

describe('getEventClassificationLabel', () => {
it('translates event classifications, pending and retrying', () => {
expect(getEventClassificationLabel('Scheduled')).toBe('Scheduled');
expect(getEventClassificationLabel('CancelRequested')).toBe(
'Cancel Requested',
);
expect(getEventClassificationLabel('Pending')).toBe('Pending');
expect(getEventClassificationLabel('Retrying')).toBe('Retrying');
});

it('resolves overlapping names via the event namespace', () => {
// Same English today, but routed through events.* — not workflows.*.
expect(getEventClassificationLabel('Completed')).toBe('Completed');
expect(getEventClassificationLabel('Failed')).toBe('Failed');
});

it('falls back to "Unknown" for undefined / unmapped values', () => {
expect(getEventClassificationLabel(undefined)).toBe('Unknown');
});
});

describe('getStatusLabel (combined, workflow-domain precedence)', () => {
it('labels both workflow statuses and event classifications', () => {
expect(getStatusLabel('Completed')).toBe('Completed');
expect(getStatusLabel('Signaled')).toBe('Signaled');
expect(getStatusLabel('Pending')).toBe('Pending');
});

it('falls back to "Unknown" for null / undefined / unmapped values', () => {
expect(getStatusLabel(undefined)).toBe('Unknown');
expect(getStatusLabel(null)).toBe('Unknown');
expect(getStatusLabel('NotAStatus' as Status)).toBe('Unknown');
});
});
90 changes: 90 additions & 0 deletions src/lib/utilities/get-status-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { I18nKey } from '$lib/i18n';
import { translate } from '$lib/i18n/translate';
import type { EventClassification } from '$lib/models/event-history/get-event-classification';
import type { ScheduleStatus } from '$lib/types/schedule';
import type { WorkflowStatus } from '$lib/types/workflows';

export type Status =
| WorkflowStatus
| ScheduleStatus
| EventClassification
| 'Pending'
| 'Retrying';

type WorkflowDomainStatus = NonNullable<WorkflowStatus | ScheduleStatus>;
type EventDomainStatus = EventClassification | 'Pending' | 'Retrying';

// Workflow / schedule statuses resolve to the workflows.* namespace.
const workflowStatusLabelKeys: Record<WorkflowDomainStatus, I18nKey> = {
Running: 'workflows.running',
TimedOut: 'workflows.timed-out',
Completed: 'workflows.completed',
Failed: 'workflows.failed',
ContinuedAsNew: 'workflows.continued-as-new',
Canceled: 'workflows.canceled',
Terminated: 'workflows.terminated',
Paused: 'workflows.paused',
};

// Event classifications resolve to the events.event-classification.* namespace,
// including names (Running, Completed, …) that also exist as workflow statuses.
const eventClassificationLabelKeys: Record<EventDomainStatus, I18nKey> = {
Unspecified: 'events.event-classification.unspecified',
Scheduled: 'events.event-classification.scheduled',
Open: 'events.event-classification.open',
New: 'events.event-classification.new',
Started: 'events.event-classification.started',
Initiated: 'events.event-classification.initiated',
Running: 'events.event-classification.running',
Completed: 'events.event-classification.completed',
Fired: 'events.event-classification.fired',
CancelRequested: 'events.event-classification.cancelrequested',
TimedOut: 'events.event-classification.timedout',
Signaled: 'events.event-classification.signaled',
Canceled: 'events.event-classification.canceled',
Failed: 'events.event-classification.failed',
Terminated: 'events.event-classification.terminated',
Pending: 'events.event-classification.pending',
Retrying: 'events.event-classification.retrying',
};

const isWorkflowStatus = (status: string): status is WorkflowDomainStatus =>
status in workflowStatusLabelKeys;

const isEventClassification = (status: string): status is EventDomainStatus =>
status in eventClassificationLabelKeys;

/** Label a workflow or schedule status (workflows.* namespace). */
export const getWorkflowStatusLabel = (
status: WorkflowStatus | ScheduleStatus | undefined,
): string =>
status && isWorkflowStatus(status)
? translate(workflowStatusLabelKeys[status])
: translate('common.unknown');

/** Label an event classification (events.event-classification.* namespace). */
export const getEventClassificationLabel = (
classification: EventDomainStatus | undefined,
): string =>
classification && isEventClassification(classification)
? translate(eventClassificationLabelKeys[classification])
: translate('common.unknown');

/**
* Polymorphic resolver for WorkflowStatus.svelte, which renders one badge for
* any status — a workflow execution status, a schedule status, or an event
* classification — and cannot tell the domain from the value alone. Names
* shared by both domains (Running, Completed, Failed, Canceled, Terminated,
* TimedOut) resolve to the workflow label, preserving the component's
* historical behavior. Domain-specific callers should prefer
* getWorkflowStatusLabel / getEventClassificationLabel instead.
*/
export const getStatusLabel = (status: Status | undefined): string => {
if (status && isWorkflowStatus(status)) {
return translate(workflowStatusLabelKeys[status]);
}
if (status && isEventClassification(status)) {
return translate(eventClassificationLabelKeys[status]);
}
return translate('common.unknown');
};
34 changes: 34 additions & 0 deletions tests/integration/timeline-graph-accessible-names.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';

import { mockWorkflowApis } from '~/test-utilities/mock-apis';
import { mockWorkflow } from '~/test-utilities/mocks/workflow';

const { workflowId, runId } = mockWorkflow.workflowExecutionInfo.execution;
const timelineUrl = `/namespaces/default/workflows/${workflowId}/${runId}/timeline`;

// The timeline graph renders SVG `<g role="button">` nodes (workflow-row and
// timeline-graph-row). Without an aria-label these announce as a bare "button"
// to screen readers; these assertions fail if that label regresses.
test.describe('Timeline graph node accessible names', () => {
test.beforeEach(async ({ page }) => {
await mockWorkflowApis(page);
await page.goto(timelineUrl);
});

test('workflow node announces its id and status', async ({ page }) => {
await expect(
page.getByRole('button', { name: `Workflow ${workflowId}: Running` }),
).toBeVisible();
});

test('event nodes announce their type and classification', async ({
page,
}) => {
await expect(
page.getByRole('button', { name: 'Event LongActivity: Scheduled' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Event customSignal: Signaled' }),
).toBeVisible();
});
});
Loading