@gntik-ai/templates 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2420 -0
- package/dist/index.js +14520 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -3
- package/src/accept-invitation/Page.test.tsx +40 -0
- package/src/accept-invitation/Page.tsx +153 -0
- package/src/accept-invitation/data.ts +22 -0
- package/src/accept-invitation/template.meta.ts +12 -0
- package/src/assistant-chat/Page.test.tsx +51 -0
- package/src/assistant-chat/Page.tsx +273 -0
- package/src/assistant-chat/data.ts +71 -0
- package/src/assistant-chat/template.meta.ts +12 -0
- package/src/audit-log/Page.test.tsx +58 -0
- package/src/audit-log/Page.tsx +284 -0
- package/src/audit-log/data.ts +133 -0
- package/src/audit-log/template.meta.ts +12 -0
- package/src/board/Lane.tsx +117 -0
- package/src/board/Page.test.tsx +68 -0
- package/src/board/Page.tsx +178 -0
- package/src/board/data.ts +127 -0
- package/src/board/template.meta.ts +12 -0
- package/src/board/useBoard.ts +146 -0
- package/src/changelog/Page.test.tsx +31 -0
- package/src/changelog/Page.tsx +174 -0
- package/src/changelog/data.ts +105 -0
- package/src/changelog/template.meta.ts +12 -0
- package/src/code-editor/Page.test.tsx +83 -0
- package/src/code-editor/Page.tsx +207 -0
- package/src/code-editor/data.ts +130 -0
- package/src/code-editor/template.meta.ts +12 -0
- package/src/contact/Page.test.tsx +33 -0
- package/src/contact/Page.tsx +272 -0
- package/src/contact/PublicFrame.tsx +84 -0
- package/src/contact/data.ts +129 -0
- package/src/contact/template.meta.ts +12 -0
- package/src/create-wizard/Overlay.test.tsx +68 -0
- package/src/create-wizard/Page.test.tsx +57 -0
- package/src/create-wizard/Page.tsx +246 -0
- package/src/create-wizard/data.ts +139 -0
- package/src/create-wizard/examples/Overlay.tsx +60 -0
- package/src/create-wizard/template.meta.ts +12 -0
- package/src/create-workspace/Page.test.tsx +41 -0
- package/src/create-workspace/Page.tsx +227 -0
- package/src/create-workspace/data.ts +61 -0
- package/src/create-workspace/template.meta.ts +12 -0
- package/src/data-explorer/Page.test.tsx +45 -0
- package/src/data-explorer/Page.tsx +247 -0
- package/src/data-explorer/data.ts +94 -0
- package/src/data-explorer/template.meta.ts +12 -0
- package/src/docs-article/ArticleFeedback.tsx +75 -0
- package/src/docs-article/Page.test.tsx +34 -0
- package/src/docs-article/Page.tsx +200 -0
- package/src/docs-article/data.ts +65 -0
- package/src/docs-article/template.meta.ts +12 -0
- package/src/evaluations/Page.test.tsx +40 -0
- package/src/evaluations/Page.tsx +238 -0
- package/src/evaluations/data.ts +194 -0
- package/src/evaluations/template.meta.ts +12 -0
- package/src/forgot-password/Page.test.tsx +199 -0
- package/src/forgot-password/Page.tsx +66 -0
- package/src/forgot-password/data.ts +5 -0
- package/src/forgot-password/examples/UsernameOrEmail.tsx +25 -0
- package/src/forgot-password/template.meta.ts +12 -0
- package/src/getting-started/Page.test.tsx +27 -0
- package/src/getting-started/Page.tsx +125 -0
- package/src/getting-started/data.ts +67 -0
- package/src/getting-started/template.meta.ts +12 -0
- package/src/help-home/Page.test.tsx +33 -0
- package/src/help-home/Page.tsx +159 -0
- package/src/help-home/data.ts +151 -0
- package/src/help-home/template.meta.ts +12 -0
- package/src/home-dashboard/Page.test.tsx +57 -0
- package/src/home-dashboard/Page.tsx +152 -0
- package/src/home-dashboard/data.ts +66 -0
- package/src/home-dashboard/template.meta.ts +12 -0
- package/src/incident-board/Page.test.tsx +77 -0
- package/src/incident-board/Page.tsx +263 -0
- package/src/incident-board/data.ts +220 -0
- package/src/incident-board/template.meta.ts +12 -0
- package/src/incident-console/Page.test.tsx +49 -0
- package/src/incident-console/Page.tsx +274 -0
- package/src/incident-console/data.ts +176 -0
- package/src/incident-console/template.meta.ts +12 -0
- package/src/index.ts +250 -0
- package/src/keyboard-shortcuts/Page.test.tsx +45 -0
- package/src/keyboard-shortcuts/Page.tsx +94 -0
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +80 -0
- package/src/keyboard-shortcuts/data.ts +84 -0
- package/src/keyboard-shortcuts/template.meta.ts +12 -0
- package/src/landing/Page.test.tsx +27 -0
- package/src/landing/Page.tsx +110 -0
- package/src/landing/data.ts +17 -0
- package/src/landing/template.meta.ts +11 -0
- package/src/live-event-stream/Page.test.tsx +251 -0
- package/src/live-event-stream/Page.tsx +307 -0
- package/src/live-event-stream/data.ts +44 -0
- package/src/live-event-stream/examples/Connecting.tsx +5 -0
- package/src/live-event-stream/examples/Error.tsx +5 -0
- package/src/live-event-stream/examples/LiveEmpty.tsx +5 -0
- package/src/live-event-stream/examples/LiveWithEvents.tsx +6 -0
- package/src/live-event-stream/examples/Stopped.tsx +6 -0
- package/src/live-event-stream/template.meta.ts +74 -0
- package/src/messaging/Page.test.tsx +34 -0
- package/src/messaging/Page.tsx +236 -0
- package/src/messaging/data.ts +118 -0
- package/src/messaging/template.meta.ts +12 -0
- package/src/meta.ts +24 -0
- package/src/mfa/Page.test.tsx +22 -0
- package/src/mfa/Page.tsx +45 -0
- package/src/mfa/data.ts +6 -0
- package/src/mfa/template.meta.ts +12 -0
- package/src/model-providers/Page.test.tsx +58 -0
- package/src/model-providers/Page.tsx +198 -0
- package/src/model-providers/data.ts +101 -0
- package/src/model-providers/template.meta.ts +12 -0
- package/src/notification-inbox/Page.test.tsx +49 -0
- package/src/notification-inbox/Page.tsx +237 -0
- package/src/notification-inbox/data.ts +129 -0
- package/src/notification-inbox/template.meta.ts +12 -0
- package/src/pending-activation/Page.test.tsx +390 -0
- package/src/pending-activation/Page.tsx +145 -0
- package/src/pending-activation/data.ts +25 -0
- package/src/pending-activation/template.meta.ts +12 -0
- package/src/pricing/ComparisonTable.tsx +75 -0
- package/src/pricing/Page.test.tsx +29 -0
- package/src/pricing/Page.tsx +83 -0
- package/src/pricing/PublicFrame.tsx +84 -0
- package/src/pricing/data.ts +104 -0
- package/src/pricing/template.meta.ts +12 -0
- package/src/prompt-playground/Page.test.tsx +72 -0
- package/src/prompt-playground/Page.tsx +254 -0
- package/src/prompt-playground/data.ts +68 -0
- package/src/prompt-playground/template.meta.ts +12 -0
- package/src/public-hub/Page.test.tsx +176 -0
- package/src/public-hub/Page.tsx +125 -0
- package/src/public-hub/Source.test.ts +54 -0
- package/src/public-hub/data.ts +24 -0
- package/src/public-hub/examples/Dark.tsx +11 -0
- package/src/public-hub/examples/Full.tsx +6 -0
- package/src/public-hub/examples/HighContrast.tsx +11 -0
- package/src/public-hub/examples/Light.tsx +11 -0
- package/src/public-hub/examples/Loading.tsx +6 -0
- package/src/public-hub/examples/Minimal.tsx +5 -0
- package/src/public-hub/examples/MinimalLoading.tsx +5 -0
- package/src/public-hub/template.meta.ts +60 -0
- package/src/reset-password/Page.test.tsx +27 -0
- package/src/reset-password/Page.tsx +46 -0
- package/src/reset-password/data.ts +5 -0
- package/src/reset-password/template.meta.ts +12 -0
- package/src/resource-detail/Page.test.tsx +260 -0
- package/src/resource-detail/Page.tsx +262 -0
- package/src/resource-detail/data.ts +55 -0
- package/src/resource-detail/examples/postgres-table-detail.tsx +28 -0
- package/src/resource-detail/examples/realtime-configuration.tsx +26 -0
- package/src/resource-detail/examples/runtime-with-transition.tsx +30 -0
- package/src/resource-detail/examples/runtime-without-transition.tsx +33 -0
- package/src/resource-detail/examples/shell.ts +12 -0
- package/src/resource-detail/template.meta.ts +21 -0
- package/src/resource-form/Page.test.tsx +40 -0
- package/src/resource-form/Page.tsx +186 -0
- package/src/resource-form/data.ts +63 -0
- package/src/resource-form/template.meta.ts +12 -0
- package/src/resource-gallery/Page.test.tsx +42 -0
- package/src/resource-gallery/Page.tsx +125 -0
- package/src/resource-gallery/data.ts +44 -0
- package/src/resource-gallery/template.meta.ts +20 -0
- package/src/resource-index/Page.test.tsx +120 -0
- package/src/resource-index/Page.tsx +216 -0
- package/src/resource-index/data.ts +84 -0
- package/src/resource-index/template.meta.ts +21 -0
- package/src/resource-tree/Page.test.tsx +31 -0
- package/src/resource-tree/Page.tsx +232 -0
- package/src/resource-tree/data.ts +180 -0
- package/src/resource-tree/template.meta.ts +12 -0
- package/src/run-trace/Page.test.tsx +56 -0
- package/src/run-trace/Page.tsx +242 -0
- package/src/run-trace/data.ts +171 -0
- package/src/run-trace/template.meta.ts +12 -0
- package/src/scorecard/Page.test.tsx +38 -0
- package/src/scorecard/Page.tsx +160 -0
- package/src/scorecard/TargetsTable.tsx +92 -0
- package/src/scorecard/data.ts +118 -0
- package/src/scorecard/template.meta.ts +12 -0
- package/src/search-results/Page.test.tsx +43 -0
- package/src/search-results/Page.tsx +243 -0
- package/src/search-results/data.ts +130 -0
- package/src/search-results/template.meta.ts +12 -0
- package/src/settings-api-keys/Page.test.tsx +39 -0
- package/src/settings-api-keys/Page.tsx +171 -0
- package/src/settings-api-keys/data.ts +58 -0
- package/src/settings-api-keys/template.meta.ts +12 -0
- package/src/settings-appearance/Page.test.tsx +44 -0
- package/src/settings-appearance/Page.tsx +92 -0
- package/src/settings-appearance/data.ts +21 -0
- package/src/settings-appearance/template.meta.ts +11 -0
- package/src/settings-billing/Page.test.tsx +31 -0
- package/src/settings-billing/Page.tsx +71 -0
- package/src/settings-billing/data.ts +15 -0
- package/src/settings-billing/template.meta.ts +19 -0
- package/src/settings-integrations/Page.test.tsx +31 -0
- package/src/settings-integrations/Page.tsx +185 -0
- package/src/settings-integrations/data.ts +116 -0
- package/src/settings-integrations/template.meta.ts +11 -0
- package/src/settings-members/Page.test.tsx +32 -0
- package/src/settings-members/Page.tsx +140 -0
- package/src/settings-members/data.ts +28 -0
- package/src/settings-members/template.meta.ts +12 -0
- package/src/settings-notifications/Page.test.tsx +31 -0
- package/src/settings-notifications/Page.tsx +71 -0
- package/src/settings-notifications/data.ts +12 -0
- package/src/settings-notifications/template.meta.ts +11 -0
- package/src/settings-profile/Baseline.test.tsx +9 -0
- package/src/settings-profile/Editable.test.tsx +21 -0
- package/src/settings-profile/Page.test.tsx +41 -0
- package/src/settings-profile/Page.tsx +133 -0
- package/src/settings-profile/ReadOnly.test.tsx +143 -0
- package/src/settings-profile/ReadOnlyProfile.tsx +85 -0
- package/src/settings-profile/SettingsFrame.test.tsx +55 -0
- package/src/settings-profile/SettingsFrame.tsx +88 -0
- package/src/settings-profile/__tests__/dom.ts +8 -0
- package/src/settings-profile/data.ts +41 -0
- package/src/settings-profile/examples/ReadOnly.tsx +31 -0
- package/src/settings-profile/fixtures/editable.snap +1 -0
- package/src/settings-profile/settings-nav.ts +98 -0
- package/src/settings-profile/template.meta.ts +21 -0
- package/src/settings-security/MfaSetupDialog.tsx +96 -0
- package/src/settings-security/Page.test.tsx +44 -0
- package/src/settings-security/Page.tsx +158 -0
- package/src/settings-security/data.ts +11 -0
- package/src/settings-security/template.meta.ts +11 -0
- package/src/settings-workspace/Page.test.tsx +44 -0
- package/src/settings-workspace/Page.tsx +120 -0
- package/src/settings-workspace/data.ts +44 -0
- package/src/settings-workspace/template.meta.ts +19 -0
- package/src/shared/ConsoleShell.test.tsx +81 -0
- package/src/shared/ConsoleShell.tsx +98 -0
- package/src/shell-sidebar/Page.test.tsx +21 -0
- package/src/shell-sidebar/Page.tsx +85 -0
- package/src/shell-sidebar/data.ts +24 -0
- package/src/shell-sidebar/template.meta.ts +12 -0
- package/src/shell-topnav/Page.test.tsx +25 -0
- package/src/shell-topnav/Page.tsx +94 -0
- package/src/shell-topnav/data.ts +25 -0
- package/src/shell-topnav/template.meta.ts +12 -0
- package/src/sign-in/BrandPanel.tsx +34 -0
- package/src/sign-in/Page.test.tsx +41 -0
- package/src/sign-in/Page.tsx +73 -0
- package/src/sign-in/data.ts +11 -0
- package/src/sign-in/template.meta.ts +12 -0
- package/src/sign-in-card/Page.test.tsx +182 -0
- package/src/sign-in-card/Page.tsx +82 -0
- package/src/sign-in-card/data.ts +8 -0
- package/src/sign-in-card/examples/Username.tsx +28 -0
- package/src/sign-in-card/template.meta.ts +12 -0
- package/src/sign-up/Page.test.tsx +33 -0
- package/src/sign-up/Page.tsx +70 -0
- package/src/sign-up/data.ts +15 -0
- package/src/sign-up/template.meta.ts +12 -0
- package/src/sso-login/Page.test.tsx +41 -0
- package/src/sso-login/Page.tsx +151 -0
- package/src/sso-login/data.ts +10 -0
- package/src/sso-login/template.meta.ts +12 -0
- package/src/status-403/Page.test.tsx +23 -0
- package/src/status-403/Page.tsx +92 -0
- package/src/status-403/data.ts +13 -0
- package/src/status-403/template.meta.ts +12 -0
- package/src/status-404/Page.test.tsx +23 -0
- package/src/status-404/Page.tsx +60 -0
- package/src/status-404/PageHeading.test.tsx +52 -0
- package/src/status-404/data.ts +12 -0
- package/src/status-404/examples/PageHeader.tsx +28 -0
- package/src/status-404/template.meta.ts +12 -0
- package/src/status-500/Page.test.tsx +24 -0
- package/src/status-500/Page.tsx +70 -0
- package/src/status-500/data.ts +20 -0
- package/src/status-500/template.meta.ts +12 -0
- package/src/status-maintenance/Page.test.tsx +24 -0
- package/src/status-maintenance/Page.tsx +84 -0
- package/src/status-maintenance/data.ts +14 -0
- package/src/status-maintenance/template.meta.ts +12 -0
- package/src/styles.css +11 -0
- package/src/test/a11y.ts +18 -0
- package/src/test/setup.ts +36 -0
- package/src/upgrade-required/Page.test.tsx +28 -0
- package/src/upgrade-required/Page.tsx +79 -0
- package/src/upgrade-required/data.ts +19 -0
- package/src/upgrade-required/template.meta.ts +12 -0
- package/src/usage-analytics/Page.test.tsx +83 -0
- package/src/usage-analytics/Page.tsx +184 -0
- package/src/usage-analytics/data.ts +161 -0
- package/src/usage-analytics/template.meta.ts +21 -0
- package/src/verify-email/Page.test.tsx +32 -0
- package/src/verify-email/Page.tsx +118 -0
- package/src/verify-email/data.ts +7 -0
- package/src/verify-email/template.meta.ts +12 -0
- package/src/workflow-builder/Page.test.tsx +97 -0
- package/src/workflow-builder/Page.tsx +163 -0
- package/src/workflow-builder/data.ts +13 -0
- package/src/workflow-builder/template.meta.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { KPI_ITEMS, PROJECT_ACTIVITY, type ActivityItem, type ChartCardRange, type KpiItem } from '@gntik-ai/blocks';
|
|
2
|
+
import { BookOpen, KeyRound, Rocket, UserPlus, type LucideIcon } from '@gntik-ai/icons';
|
|
3
|
+
import type { BreadcrumbItem } from '@gntik-ai/ui';
|
|
4
|
+
|
|
5
|
+
/** A shortcut card in the "Quick actions" grid. */
|
|
6
|
+
export interface QuickAction {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
icon: LucideIcon;
|
|
11
|
+
href?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RequestPoint {
|
|
15
|
+
day: string;
|
|
16
|
+
Requests: number;
|
|
17
|
+
Errors: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const homeBreadcrumbs: BreadcrumbItem[] = [{ label: 'Overview' }];
|
|
21
|
+
|
|
22
|
+
export const homeKpis: KpiItem[] = KPI_ITEMS;
|
|
23
|
+
|
|
24
|
+
export const homeActivity: ActivityItem[] = PROJECT_ACTIVITY;
|
|
25
|
+
|
|
26
|
+
export const homeQuickActions: QuickAction[] = [
|
|
27
|
+
{ id: 'deploy', title: 'Deploy a service', description: 'Ship a new build from a branch or tag.', icon: Rocket },
|
|
28
|
+
{ id: 'invite', title: 'Invite members', description: 'Add teammates and pick their role.', icon: UserPlus },
|
|
29
|
+
{ id: 'api-key', title: 'Create an API key', description: 'Scoped keys for CI and integrations.', icon: KeyRound },
|
|
30
|
+
{
|
|
31
|
+
id: 'docs',
|
|
32
|
+
title: 'Read the docs',
|
|
33
|
+
description: 'Guides, API reference and examples.',
|
|
34
|
+
icon: BookOpen,
|
|
35
|
+
href: '#docs',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const DAYS7 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
40
|
+
const DAYS30 = Array.from({ length: 30 }, (_, i) => `Day ${i + 1}`);
|
|
41
|
+
|
|
42
|
+
const requestSeries = (labels: string[], base: number, step: number): RequestPoint[] =>
|
|
43
|
+
labels.map((day, i) => ({
|
|
44
|
+
day,
|
|
45
|
+
Requests: Math.round(base + step * i + ((i * 37) % 11) * 180),
|
|
46
|
+
Errors: Math.round(40 + ((i * 13) % 7) * 9),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
/** Requests and errors per day, per range. */
|
|
50
|
+
export const homeRequestsByRange: Record<string, RequestPoint[]> = {
|
|
51
|
+
'7d': requestSeries(DAYS7, 31_000, 1_200),
|
|
52
|
+
'30d': requestSeries(DAYS30, 28_000, 340),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const homeRequestRanges: ChartCardRange[] = [
|
|
56
|
+
{
|
|
57
|
+
value: '7d',
|
|
58
|
+
label: '7d',
|
|
59
|
+
summary: { value: '271,480', delta: { value: '+12.4%', direction: 'up', sentiment: 'positive' } },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
value: '30d',
|
|
63
|
+
label: '30d',
|
|
64
|
+
summary: { value: '1.08M', delta: { value: '+6.0%', direction: 'up', sentiment: 'positive' } },
|
|
65
|
+
},
|
|
66
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Home dashboard',
|
|
5
|
+
family: 'Overview',
|
|
6
|
+
priority: 'P1',
|
|
7
|
+
status: 'beta',
|
|
8
|
+
description:
|
|
9
|
+
'Workspace home in the console shell: page header, KPI row, cost and traffic charts, recent activity and quick-action cards.',
|
|
10
|
+
layout: 'SidebarLayout + Page',
|
|
11
|
+
blocks: ['app-sidebar', 'app-topbar', 'page-header', 'kpi-row', 'chart-card', 'activity-feed'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
|
+
import IncidentBoardPage from './Page';
|
|
5
|
+
import { applyVisibleMove, formatAge, triageColumns } from './data';
|
|
6
|
+
|
|
7
|
+
const column = (name: RegExp) => screen.getByRole('region', { name });
|
|
8
|
+
const status = () =>
|
|
9
|
+
screen
|
|
10
|
+
.getAllByRole('status')
|
|
11
|
+
.map((s) => s.textContent?.trim() ?? '')
|
|
12
|
+
.join(' ');
|
|
13
|
+
|
|
14
|
+
describe('IncidentBoardPage', () => {
|
|
15
|
+
it('renders the header, KPIs, filters and the board by stage', { timeout: 15000 }, async () => {
|
|
16
|
+
render(<IncidentBoardPage />);
|
|
17
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Incident board' })).toBeInTheDocument();
|
|
19
|
+
expect(screen.getAllByRole('button', { name: 'Declare incident' })[0]).toBeInTheDocument();
|
|
20
|
+
const kpis = screen.getByLabelText('Incident summary');
|
|
21
|
+
expect(within(kpis).getByText('Open incidents').nextElementSibling).toHaveTextContent('5');
|
|
22
|
+
expect(screen.getByRole('group', { name: 'Incidents by stage' })).toBeInTheDocument();
|
|
23
|
+
for (const stage of ['Triage', 'Investigating', 'Mitigated', 'Resolved'])
|
|
24
|
+
expect(screen.getByRole('heading', { level: 2, name: new RegExp(stage) })).toBeInTheDocument();
|
|
25
|
+
const card = within(column(/Triage/)).getByRole('button', { name: /INC-3120/ });
|
|
26
|
+
expect(card).toHaveTextContent('SEV1');
|
|
27
|
+
expect(card).toHaveTextContent('Unassigned');
|
|
28
|
+
expect(card).toHaveTextContent('12m');
|
|
29
|
+
await expectNoAxeViolations();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('moves a card with the keyboard, announces it and reports the move', { timeout: 15000 }, async () => {
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
const onMove = vi.fn();
|
|
35
|
+
render(<IncidentBoardPage onMove={onMove} />);
|
|
36
|
+
within(column(/Triage/))
|
|
37
|
+
.getByRole('button', { name: /INC-3120/ })
|
|
38
|
+
.focus();
|
|
39
|
+
await user.keyboard(' {ArrowRight} ');
|
|
40
|
+
expect(within(column(/Investigating/)).getByRole('button', { name: /INC-3120/ })).toBeInTheDocument();
|
|
41
|
+
expect(status()).toMatch(/INC-3120 Checkout latency above 2 s, SEV1 dropped at position \d of 3 in Investigating/);
|
|
42
|
+
expect(onMove).toHaveBeenCalledWith(
|
|
43
|
+
expect.objectContaining({ cardId: 'INC-3120', fromColumnId: 'triage', toColumnId: 'investigating' }),
|
|
44
|
+
expect.any(Array),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('filters the board and opens the details drawer', { timeout: 15000 }, async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
render(<IncidentBoardPage />);
|
|
51
|
+
await user.type(screen.getByRole('searchbox'), 'webhook');
|
|
52
|
+
const board = screen.getByRole('group', { name: 'Incidents by stage' });
|
|
53
|
+
expect(within(board).getAllByRole('button', { name: /INC-\d+/ })).toHaveLength(1);
|
|
54
|
+
await user.click(screen.getByRole('button', { name: /INC-3111/ }));
|
|
55
|
+
const drawer = await screen.findByRole('dialog', { name: 'Webhook deliveries delayed' });
|
|
56
|
+
expect(within(drawer).getByText('Linus Ortega')).toBeInTheDocument();
|
|
57
|
+
expect(within(drawer).getByText('SEV2')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('maps a move on the filtered board to full-board indices', () => {
|
|
61
|
+
const visible = triageColumns.map((c) => ({ ...c, cards: c.cards.filter((i) => i.severity === 'sev2') }));
|
|
62
|
+
const { columns, move } = applyVisibleMove(triageColumns, visible, {
|
|
63
|
+
cardId: 'INC-3111',
|
|
64
|
+
fromColumnId: 'investigating',
|
|
65
|
+
fromIndex: 0,
|
|
66
|
+
toColumnId: 'resolved',
|
|
67
|
+
toIndex: 0,
|
|
68
|
+
});
|
|
69
|
+
expect(move).toMatchObject({ fromIndex: 1, toIndex: 1 });
|
|
70
|
+
expect(columns.find((c) => c.id === 'resolved')?.cards.map((i) => i.id)).toEqual([
|
|
71
|
+
'INC-3097',
|
|
72
|
+
'INC-3111',
|
|
73
|
+
'INC-3092',
|
|
74
|
+
]);
|
|
75
|
+
expect(formatAge(0, 3 * 60 * 60_000 + 5 * 60_000)).toBe('3h 5m');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { FilterBar, KpiRow, PageHeader, type ActiveFilter, type FilterField } from '@gntik-ai/blocks';
|
|
2
|
+
import { Download, Plus } from '@gntik-ai/icons';
|
|
3
|
+
import {
|
|
4
|
+
Avatar,
|
|
5
|
+
Drawer,
|
|
6
|
+
DrawerBody,
|
|
7
|
+
DrawerContent,
|
|
8
|
+
DrawerDescription,
|
|
9
|
+
DrawerHeader,
|
|
10
|
+
DrawerTitle,
|
|
11
|
+
HStack,
|
|
12
|
+
KanbanBoard,
|
|
13
|
+
LiveAnnouncer,
|
|
14
|
+
Page,
|
|
15
|
+
SimpleSelect,
|
|
16
|
+
Stack,
|
|
17
|
+
StatusTag,
|
|
18
|
+
Text,
|
|
19
|
+
Timestamp,
|
|
20
|
+
useAnnounce,
|
|
21
|
+
type BreadcrumbItem,
|
|
22
|
+
type KanbanColumn,
|
|
23
|
+
type KanbanMove,
|
|
24
|
+
} from '@gntik-ai/ui';
|
|
25
|
+
import { useState } from 'react';
|
|
26
|
+
import { ConsoleShell, type ConsoleShellProps } from '../shared/ConsoleShell';
|
|
27
|
+
import {
|
|
28
|
+
applyVisibleMove,
|
|
29
|
+
formatAge,
|
|
30
|
+
matchesTriage,
|
|
31
|
+
triageBreadcrumbs,
|
|
32
|
+
triageColumns,
|
|
33
|
+
triageFilterFields,
|
|
34
|
+
triageKpis,
|
|
35
|
+
triageNow,
|
|
36
|
+
triageSeverities,
|
|
37
|
+
type TriageIncident,
|
|
38
|
+
} from './data';
|
|
39
|
+
|
|
40
|
+
export interface IncidentBoardProps {
|
|
41
|
+
title: string;
|
|
42
|
+
description: string;
|
|
43
|
+
/** Initial columns (stages) and incident cards. */
|
|
44
|
+
defaultColumns: KanbanColumn<TriageIncident>[];
|
|
45
|
+
filterFields: FilterField[];
|
|
46
|
+
defaultFilters: ActiveFilter[];
|
|
47
|
+
/** Clock for the card ages (epoch ms). */
|
|
48
|
+
now: number;
|
|
49
|
+
/** Fires after a card moves (drag, keyboard or the drawer's status select), in full-board indices. */
|
|
50
|
+
onMove: (move: KanbanMove, columns: KanbanColumn<TriageIncident>[]) => void;
|
|
51
|
+
/** Initially open incident (details drawer). */
|
|
52
|
+
defaultOpenId: string | null;
|
|
53
|
+
onDeclare: () => void;
|
|
54
|
+
onExport: () => void;
|
|
55
|
+
breadcrumbs: BreadcrumbItem[];
|
|
56
|
+
currentHref: string;
|
|
57
|
+
shell: Omit<ConsoleShellProps, 'children'>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Handlers = 'onMove' | 'onDeclare' | 'onExport';
|
|
61
|
+
type BoardProps = Omit<IncidentBoardProps, 'breadcrumbs' | 'currentHref' | 'shell' | Handlers> &
|
|
62
|
+
Partial<Pick<IncidentBoardProps, Handlers>>;
|
|
63
|
+
|
|
64
|
+
function IncidentCard({ incident, now }: { incident: TriageIncident; now: number }) {
|
|
65
|
+
return (
|
|
66
|
+
<span className="grid gap-2">
|
|
67
|
+
<span className="text-[13px] leading-5 font-medium text-foreground">{incident.title}</span>
|
|
68
|
+
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
69
|
+
<StatusTag status={incident.severity} statuses={triageSeverities} size="sm" />
|
|
70
|
+
<span className="font-mono text-[11px] text-muted-foreground">{incident.id}</span>
|
|
71
|
+
</span>
|
|
72
|
+
<span className="flex items-center justify-between gap-2 text-[12px] text-muted-foreground">
|
|
73
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
74
|
+
<Avatar size="xs" name={incident.owner} aria-hidden />
|
|
75
|
+
<span className="truncate">{incident.owner}</span>
|
|
76
|
+
</span>
|
|
77
|
+
<span className="font-mono tabular-nums">
|
|
78
|
+
<span className="sr-only">Age </span>
|
|
79
|
+
{formatAge(incident.openedAt, now)}
|
|
80
|
+
</span>
|
|
81
|
+
</span>
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function IncidentBoard({
|
|
87
|
+
title,
|
|
88
|
+
description,
|
|
89
|
+
defaultColumns,
|
|
90
|
+
filterFields,
|
|
91
|
+
defaultFilters,
|
|
92
|
+
now,
|
|
93
|
+
onMove,
|
|
94
|
+
defaultOpenId,
|
|
95
|
+
onDeclare,
|
|
96
|
+
onExport,
|
|
97
|
+
}: BoardProps) {
|
|
98
|
+
const announce = useAnnounce();
|
|
99
|
+
const [columns, setColumns] = useState(defaultColumns);
|
|
100
|
+
const [query, setQuery] = useState('');
|
|
101
|
+
const [filters, setFilters] = useState<ActiveFilter[]>(defaultFilters);
|
|
102
|
+
const [openId, setOpenId] = useState<string | null>(defaultOpenId);
|
|
103
|
+
|
|
104
|
+
const visible = columns.map((c) => ({ ...c, cards: c.cards.filter((i) => matchesTriage(i, query, filters)) }));
|
|
105
|
+
const shown = visible.reduce((n, c) => n + c.cards.length, 0);
|
|
106
|
+
const openColumn = columns.find((c) => c.cards.some((i) => i.id === openId));
|
|
107
|
+
const open = openColumn?.cards.find((i) => i.id === openId);
|
|
108
|
+
|
|
109
|
+
const commit = (next: KanbanColumn<TriageIncident>[], move: KanbanMove) => {
|
|
110
|
+
setColumns(next);
|
|
111
|
+
onMove?.(move, next);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const moveFromDrawer = (incident: TriageIncident, toColumnId: string) => {
|
|
115
|
+
const from = columns.find((c) => c.cards.some((i) => i.id === incident.id));
|
|
116
|
+
const to = columns.find((c) => c.id === toColumnId);
|
|
117
|
+
if (!from || !to || from.id === to.id) return;
|
|
118
|
+
const result = applyVisibleMove(columns, columns, {
|
|
119
|
+
cardId: incident.id,
|
|
120
|
+
fromColumnId: from.id,
|
|
121
|
+
fromIndex: from.cards.findIndex((i) => i.id === incident.id),
|
|
122
|
+
toColumnId: to.id,
|
|
123
|
+
toIndex: to.cards.length,
|
|
124
|
+
});
|
|
125
|
+
commit(result.columns, result.move);
|
|
126
|
+
announce(`${incident.title} moved to ${to.title}.`);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<Page
|
|
132
|
+
width="full"
|
|
133
|
+
header={
|
|
134
|
+
<PageHeader
|
|
135
|
+
breadcrumbs={null}
|
|
136
|
+
title={title}
|
|
137
|
+
description={description}
|
|
138
|
+
status=""
|
|
139
|
+
meta={[]}
|
|
140
|
+
tabs={null}
|
|
141
|
+
actions={[
|
|
142
|
+
{ label: 'Export', icon: Download, variant: 'secondary', onClick: onExport },
|
|
143
|
+
{ label: 'Declare incident', icon: Plus, variant: 'primary', onClick: onDeclare },
|
|
144
|
+
]}
|
|
145
|
+
/>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
<Stack gap={5}>
|
|
149
|
+
<KpiRow items={triageKpis(columns)} variant="strip" showTrends={false} label="Incident summary" />
|
|
150
|
+
<FilterBar
|
|
151
|
+
fields={filterFields}
|
|
152
|
+
views={[]}
|
|
153
|
+
query={query}
|
|
154
|
+
onQueryChange={setQuery}
|
|
155
|
+
filters={filters}
|
|
156
|
+
onFiltersChange={setFilters}
|
|
157
|
+
placeholder="Search id, title, service or owner…"
|
|
158
|
+
resultCount={shown}
|
|
159
|
+
/>
|
|
160
|
+
<KanbanBoard<TriageIncident>
|
|
161
|
+
label="Incidents by stage"
|
|
162
|
+
headingLevel={2}
|
|
163
|
+
emptyText="No incidents"
|
|
164
|
+
columns={visible}
|
|
165
|
+
onMove={(move) => {
|
|
166
|
+
const result = applyVisibleMove(columns, visible, move);
|
|
167
|
+
commit(result.columns, result.move);
|
|
168
|
+
}}
|
|
169
|
+
getCardLabel={(i) => `${i.id} ${i.title}, ${triageSeverities[i.severity].label}`}
|
|
170
|
+
onCardOpen={(i) => setOpenId(i.id)}
|
|
171
|
+
renderCard={(i) => <IncidentCard incident={i} now={now} />}
|
|
172
|
+
/>
|
|
173
|
+
</Stack>
|
|
174
|
+
</Page>
|
|
175
|
+
<Drawer open={open != null} onOpenChange={(next) => !next && setOpenId(null)}>
|
|
176
|
+
<DrawerContent size="md">
|
|
177
|
+
{open && openColumn && (
|
|
178
|
+
<>
|
|
179
|
+
<DrawerHeader>
|
|
180
|
+
<DrawerTitle>{open.title}</DrawerTitle>
|
|
181
|
+
<DrawerDescription>
|
|
182
|
+
{open.id} · {open.service}
|
|
183
|
+
</DrawerDescription>
|
|
184
|
+
</DrawerHeader>
|
|
185
|
+
<DrawerBody>
|
|
186
|
+
<Stack gap={5}>
|
|
187
|
+
<HStack gap={2} wrap>
|
|
188
|
+
<StatusTag status={open.severity} statuses={triageSeverities} />
|
|
189
|
+
<Text as="span" variant="caption" tone="muted">
|
|
190
|
+
Open for {formatAge(open.openedAt, now)}
|
|
191
|
+
</Text>
|
|
192
|
+
</HStack>
|
|
193
|
+
<Text variant="supporting">{open.summary}</Text>
|
|
194
|
+
<dl className="grid grid-cols-[7rem_1fr] items-center gap-x-4 gap-y-3 text-[13px]">
|
|
195
|
+
<dt className="text-muted-foreground">Stage</dt>
|
|
196
|
+
<dd>
|
|
197
|
+
<SimpleSelect
|
|
198
|
+
aria-label="Stage"
|
|
199
|
+
size="sm"
|
|
200
|
+
items={columns.map((c) => ({ value: c.id, label: c.title }))}
|
|
201
|
+
value={openColumn.id}
|
|
202
|
+
onValueChange={(v) => v && moveFromDrawer(open, v)}
|
|
203
|
+
className="w-44"
|
|
204
|
+
/>
|
|
205
|
+
</dd>
|
|
206
|
+
<dt className="text-muted-foreground">Owner</dt>
|
|
207
|
+
<dd className="flex items-center gap-2 text-foreground">
|
|
208
|
+
<Avatar size="xs" name={open.owner} aria-hidden />
|
|
209
|
+
{open.owner}
|
|
210
|
+
</dd>
|
|
211
|
+
<dt className="text-muted-foreground">Service</dt>
|
|
212
|
+
<dd className="font-mono text-foreground">{open.service}</dd>
|
|
213
|
+
<dt className="text-muted-foreground">Declared</dt>
|
|
214
|
+
<dd className="text-foreground">
|
|
215
|
+
<Timestamp value={open.openedAt} format="absolute" tooltip={false} />
|
|
216
|
+
</dd>
|
|
217
|
+
</dl>
|
|
218
|
+
</Stack>
|
|
219
|
+
</DrawerBody>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</DrawerContent>
|
|
223
|
+
</Drawer>
|
|
224
|
+
</>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Incident triage board: KPI summary, FilterBar and a KanbanBoard by stage with keyboard moves and a details Drawer. */
|
|
229
|
+
export default function IncidentBoardPage(props: Partial<IncidentBoardProps>) {
|
|
230
|
+
const {
|
|
231
|
+
title = 'Incident board',
|
|
232
|
+
description = 'Triage incidents by stage. Drag a card, or focus it and press Space to pick it up; Enter opens the details.',
|
|
233
|
+
defaultColumns = triageColumns,
|
|
234
|
+
filterFields = triageFilterFields,
|
|
235
|
+
defaultFilters = [],
|
|
236
|
+
now = triageNow,
|
|
237
|
+
onMove,
|
|
238
|
+
defaultOpenId = null,
|
|
239
|
+
onDeclare,
|
|
240
|
+
onExport,
|
|
241
|
+
breadcrumbs = triageBreadcrumbs,
|
|
242
|
+
currentHref = '/incidents',
|
|
243
|
+
shell,
|
|
244
|
+
} = props;
|
|
245
|
+
return (
|
|
246
|
+
<ConsoleShell breadcrumbs={breadcrumbs} currentHref={currentHref} {...shell}>
|
|
247
|
+
<LiveAnnouncer>
|
|
248
|
+
<IncidentBoard
|
|
249
|
+
title={title}
|
|
250
|
+
description={description}
|
|
251
|
+
defaultColumns={defaultColumns}
|
|
252
|
+
filterFields={filterFields}
|
|
253
|
+
defaultFilters={defaultFilters}
|
|
254
|
+
now={now}
|
|
255
|
+
onMove={onMove}
|
|
256
|
+
defaultOpenId={defaultOpenId}
|
|
257
|
+
onDeclare={onDeclare}
|
|
258
|
+
onExport={onExport}
|
|
259
|
+
/>
|
|
260
|
+
</LiveAnnouncer>
|
|
261
|
+
</ConsoleShell>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ActiveFilter, FilterField, KpiItem } from '@gntik-ai/blocks';
|
|
2
|
+
import {
|
|
3
|
+
moveKanbanCard,
|
|
4
|
+
type BreadcrumbItem,
|
|
5
|
+
type KanbanCard,
|
|
6
|
+
type KanbanColumn,
|
|
7
|
+
type KanbanMove,
|
|
8
|
+
type StatusDefinition,
|
|
9
|
+
} from '@gntik-ai/ui';
|
|
10
|
+
|
|
11
|
+
export type TriageSeverity = 'sev1' | 'sev2' | 'sev3';
|
|
12
|
+
export type TriageStage = 'triage' | 'investigating' | 'mitigated' | 'resolved';
|
|
13
|
+
|
|
14
|
+
/** One incident card on the triage board. */
|
|
15
|
+
export interface TriageIncident extends KanbanCard {
|
|
16
|
+
severity: TriageSeverity;
|
|
17
|
+
service: string;
|
|
18
|
+
owner: string;
|
|
19
|
+
/** Epoch ms when the incident was declared. */
|
|
20
|
+
openedAt: number;
|
|
21
|
+
summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const triageSeverities: Record<TriageSeverity, StatusDefinition> = {
|
|
25
|
+
sev1: { label: 'SEV1', tone: 'destructive' },
|
|
26
|
+
sev2: { label: 'SEV2', tone: 'warning' },
|
|
27
|
+
sev3: { label: 'SEV3', tone: 'info' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const triageStageLabels: Record<TriageStage, string> = {
|
|
31
|
+
triage: 'Triage',
|
|
32
|
+
investigating: 'Investigating',
|
|
33
|
+
mitigated: 'Mitigated',
|
|
34
|
+
resolved: 'Resolved',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const UNASSIGNED = 'Unassigned';
|
|
38
|
+
|
|
39
|
+
/** Compact age ("45m", "3h 10m", "2d 4h") of an incident opened at `openedAt`. */
|
|
40
|
+
export function formatAge(openedAt: number, now: number): string {
|
|
41
|
+
const minutes = Math.max(0, Math.floor((now - openedAt) / 60_000));
|
|
42
|
+
if (minutes < 60) return `${minutes}m`;
|
|
43
|
+
const hours = Math.floor(minutes / 60);
|
|
44
|
+
if (hours < 24) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
|
|
45
|
+
const days = Math.floor(hours / 24);
|
|
46
|
+
return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MIN = 60_000;
|
|
50
|
+
/** Fixture clock: ages are computed against this instant. */
|
|
51
|
+
export const triageNow = Date.now();
|
|
52
|
+
const ago = (minutes: number) => triageNow - minutes * MIN;
|
|
53
|
+
|
|
54
|
+
const incident = (
|
|
55
|
+
id: string,
|
|
56
|
+
title: string,
|
|
57
|
+
severity: TriageSeverity,
|
|
58
|
+
service: string,
|
|
59
|
+
owner: string,
|
|
60
|
+
minutesAgo: number,
|
|
61
|
+
summary: string,
|
|
62
|
+
): TriageIncident => ({ id, title, severity, service, owner, openedAt: ago(minutesAgo), summary });
|
|
63
|
+
|
|
64
|
+
export const triageColumns: KanbanColumn<TriageIncident>[] = [
|
|
65
|
+
{
|
|
66
|
+
id: 'triage',
|
|
67
|
+
title: triageStageLabels.triage,
|
|
68
|
+
cards: [
|
|
69
|
+
incident(
|
|
70
|
+
'INC-3120',
|
|
71
|
+
'Checkout latency above 2 s',
|
|
72
|
+
'sev1',
|
|
73
|
+
'payments-api',
|
|
74
|
+
UNASSIGNED,
|
|
75
|
+
12,
|
|
76
|
+
'p95 latency on checkout doubled after the 14:00 deploy; error budget burning at 6×.',
|
|
77
|
+
),
|
|
78
|
+
incident(
|
|
79
|
+
'INC-3118',
|
|
80
|
+
'Search results missing new items',
|
|
81
|
+
'sev3',
|
|
82
|
+
'search-index',
|
|
83
|
+
UNASSIGNED,
|
|
84
|
+
95,
|
|
85
|
+
'Items created in the last hour do not appear in search; indexing lag is growing.',
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'investigating',
|
|
91
|
+
title: triageStageLabels.investigating,
|
|
92
|
+
cards: [
|
|
93
|
+
incident(
|
|
94
|
+
'INC-3115',
|
|
95
|
+
'Elevated 5xx on the API gateway',
|
|
96
|
+
'sev1',
|
|
97
|
+
'api-gateway',
|
|
98
|
+
'Ada Park',
|
|
99
|
+
48,
|
|
100
|
+
'One availability zone returns 502s for 4% of requests; traffic shift in progress.',
|
|
101
|
+
),
|
|
102
|
+
incident(
|
|
103
|
+
'INC-3111',
|
|
104
|
+
'Webhook deliveries delayed',
|
|
105
|
+
'sev2',
|
|
106
|
+
'webhooks',
|
|
107
|
+
'Linus Ortega',
|
|
108
|
+
190,
|
|
109
|
+
'Delivery queue depth at 40k; workers restart on an out-of-memory error.',
|
|
110
|
+
),
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'mitigated',
|
|
115
|
+
title: triageStageLabels.mitigated,
|
|
116
|
+
cards: [
|
|
117
|
+
incident(
|
|
118
|
+
'INC-3104',
|
|
119
|
+
'Sign-in emails bouncing',
|
|
120
|
+
'sev2',
|
|
121
|
+
'auth-service',
|
|
122
|
+
'Grace Kim',
|
|
123
|
+
420,
|
|
124
|
+
'Mail provider rate limit hit; traffic moved to the backup sender.',
|
|
125
|
+
),
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'resolved',
|
|
130
|
+
title: triageStageLabels.resolved,
|
|
131
|
+
cards: [
|
|
132
|
+
incident(
|
|
133
|
+
'INC-3097',
|
|
134
|
+
'Dashboard charts not loading',
|
|
135
|
+
'sev3',
|
|
136
|
+
'analytics',
|
|
137
|
+
'Mei Chen',
|
|
138
|
+
1500,
|
|
139
|
+
'A stale CDN asset broke the chart bundle; cache purged and a fix deployed.',
|
|
140
|
+
),
|
|
141
|
+
incident(
|
|
142
|
+
'INC-3092',
|
|
143
|
+
'Billing worker stuck on retries',
|
|
144
|
+
'sev2',
|
|
145
|
+
'billing-worker',
|
|
146
|
+
'Ada Park',
|
|
147
|
+
2900,
|
|
148
|
+
'A malformed invoice blocked the queue; it was skipped and the parser fixed.',
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
const services = [...new Set(triageColumns.flatMap((c) => c.cards.map((i) => i.service)))].sort();
|
|
155
|
+
const owners = [...new Set(triageColumns.flatMap((c) => c.cards.map((i) => i.owner)))].sort();
|
|
156
|
+
|
|
157
|
+
export const triageFilterFields: FilterField[] = [
|
|
158
|
+
{ id: 'severity', label: 'Severity', options: Object.values(triageSeverities).map((s) => s.label) },
|
|
159
|
+
{ id: 'service', label: 'Service', options: services },
|
|
160
|
+
{ id: 'owner', label: 'Owner', options: owners },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/** Filter value of an incident for a FilterBar field id. */
|
|
164
|
+
export function triageFieldValue(i: TriageIncident, field: string): string | undefined {
|
|
165
|
+
if (field === 'severity') return triageSeverities[i.severity].label;
|
|
166
|
+
if (field === 'service') return i.service;
|
|
167
|
+
if (field === 'owner') return i.owner;
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when the incident matches the search text and every filtered field (OR within a field). */
|
|
172
|
+
export function matchesTriage(i: TriageIncident, query: string, filters: readonly ActiveFilter[]): boolean {
|
|
173
|
+
const q = query.trim().toLowerCase();
|
|
174
|
+
if (q && ![i.id, i.title, i.service, i.owner].some((v) => v.toLowerCase().includes(q))) return false;
|
|
175
|
+
const byField = new Map<string, string[]>();
|
|
176
|
+
for (const f of filters) byField.set(f.field, [...(byField.get(f.field) ?? []), f.value]);
|
|
177
|
+
return [...byField].every(([field, values]) => values.includes(triageFieldValue(i, field) ?? ''));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** KPI tiles computed from the board. */
|
|
181
|
+
export function triageKpis(columns: KanbanColumn<TriageIncident>[]): KpiItem[] {
|
|
182
|
+
const cards = (id: string) => columns.find((c) => c.id === id)?.cards ?? [];
|
|
183
|
+
const open = columns.filter((c) => c.id !== 'resolved').flatMap((c) => c.cards);
|
|
184
|
+
return [
|
|
185
|
+
{ id: 'open', label: 'Open incidents', value: String(open.length) },
|
|
186
|
+
{ id: 'sev1', label: 'Open SEV1', value: String(open.filter((i) => i.severity === 'sev1').length) },
|
|
187
|
+
{ id: 'unassigned', label: 'Unassigned', value: String(open.filter((i) => i.owner === UNASSIGNED).length) },
|
|
188
|
+
{ id: 'resolved', label: 'Resolved', value: String(cards('resolved').length) },
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const triageBreadcrumbs: BreadcrumbItem[] = [
|
|
193
|
+
{ label: 'Acme Industries', href: '/overview' },
|
|
194
|
+
{ label: 'Incidents' },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Maps a move made on the filtered (visible) board onto the full board: the card lands before the
|
|
199
|
+
* visible card it was dropped on, or after the last visible one. Returns the new columns and the
|
|
200
|
+
* move in full-board indices.
|
|
201
|
+
*/
|
|
202
|
+
export function applyVisibleMove<T extends KanbanCard>(
|
|
203
|
+
full: KanbanColumn<T>[],
|
|
204
|
+
visible: KanbanColumn<T>[],
|
|
205
|
+
move: KanbanMove,
|
|
206
|
+
): { columns: KanbanColumn<T>[]; move: KanbanMove } {
|
|
207
|
+
const cardsOf = (cols: KanbanColumn<T>[], id: string) => cols.find((c) => c.id === id)?.cards ?? [];
|
|
208
|
+
const fromIndex = cardsOf(full, move.fromColumnId).findIndex((c) => c.id === move.cardId);
|
|
209
|
+
const destVisible = cardsOf(visible, move.toColumnId).filter((c) => c.id !== move.cardId);
|
|
210
|
+
const destFull = cardsOf(full, move.toColumnId).filter((c) => c.id !== move.cardId);
|
|
211
|
+
const anchor = destVisible[move.toIndex];
|
|
212
|
+
const last = destVisible[destVisible.length - 1];
|
|
213
|
+
const toIndex = anchor
|
|
214
|
+
? destFull.findIndex((c) => c.id === anchor.id)
|
|
215
|
+
: last
|
|
216
|
+
? destFull.findIndex((c) => c.id === last.id) + 1
|
|
217
|
+
: destFull.length;
|
|
218
|
+
const fullMove = { ...move, fromIndex, toIndex };
|
|
219
|
+
return { columns: moveKanbanCard(full, fullMove), move: fullMove };
|
|
220
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Incident board',
|
|
5
|
+
family: 'Overview',
|
|
6
|
+
priority: 'P3',
|
|
7
|
+
status: 'experimental',
|
|
8
|
+
description:
|
|
9
|
+
'Incident triage Kanban in the console: page header with Export / Declare incident, a KPI strip (open, SEV1, unassigned, resolved), a FilterBar (search, severity, service, owner) and a KanbanBoard by stage (Triage · Investigating · Mitigated · Resolved) whose cards show severity, owner and age; pointer and keyboard moves with live announcements, and a details Drawer whose stage select also moves the card.',
|
|
10
|
+
layout: 'SidebarLayout · Page',
|
|
11
|
+
blocks: ['app-sidebar', 'app-topbar', 'page-header', 'kpi-row', 'filter-bar'],
|
|
12
|
+
};
|