diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index 3aa26f6e..a9f77fd4 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -34,6 +34,7 @@ import { PropertyValue, } from "@/components/content/property-list"; import { SectionGroup } from "@/components/content/section"; +import { Timeline } from "@/components/content/timeline"; import { IncidentActions, hasIncidentActions, @@ -43,7 +44,7 @@ import { IncidentHeading } from "@/components/incidents/incident-heading"; import { IncidentPostmortem } from "@/components/incidents/incident-postmortem"; import { IncidentProperties } from "@/components/incidents/incident-properties"; import { IncidentStatusReport } from "@/components/incidents/incident-status-report"; -import { IncidentTimeline } from "@/components/incidents/incident-timeline"; +import { IncidentTimelineItem } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; import { incidentEndedAt, personName } from "@/data/managed-incidents.client"; import { useFeature } from "@/hooks/use-feature"; @@ -160,14 +161,18 @@ export function Client({ id }: { id: number }) { Postmortem - - {closed ? null : ( - - )} - + + + {closed ? null : ( + + )} + {events?.map((event) => ( + + ))} + + - + ); } diff --git a/apps/dashboard/src/components/incidents/incident-properties.tsx b/apps/dashboard/src/components/incidents/incident-properties.tsx index b08ec731..d824d763 100644 --- a/apps/dashboard/src/components/incidents/incident-properties.tsx +++ b/apps/dashboard/src/components/incidents/incident-properties.tsx @@ -160,7 +160,10 @@ export function IncidentProperties({ {closed ? ( commander ? ( <> - + {commander} ) : ( @@ -192,7 +195,7 @@ export function IncidentProperties({ value={String(member.user.id)} className="font-mono" > - + {name} ); diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx index c1e3c7c1..921fd739 100644 --- a/apps/dashboard/src/components/incidents/incident-timeline.tsx +++ b/apps/dashboard/src/components/incidents/incident-timeline.tsx @@ -24,11 +24,11 @@ import { Unlinked, Warning, } from "@openstatus/icons"; +import { SlackIcon } from "@openstatus/icons/brand"; import type { StatusVariant } from "@/components/common/status-dot"; import { ProcessMessage } from "@/components/content/process-message"; import { - Timeline, TimelineBody, TimelineContent, TimelineHeader, @@ -77,6 +77,8 @@ const eventConfig: Record< const TRANSITION = /^\w+ changed from \w+ to (\w+)(?:\n\n([\s\S]+))?$/; const DECLARED = /^Declared as (\w+): /; +// Slack-mirrored notes end with a permalink line appended by the bot. +const FROM_SLACK = /\n\n\[From Slack\]\((https?:\/\/\S+)\)$/; // Events carry no structured payload, so the target of a transition is read // from the service's own message. An unknown shape falls back to the raw text. @@ -84,10 +86,18 @@ function parseEvent(event: Event): { status?: IncidentStatus; severity?: IncidentSeverity; message: string | null; + slackUrl?: string; } { const message = event.message; if (!message) return { message: null }; + if (event.type === "note") { + const match = FROM_SLACK.exec(message); + if (match) { + return { message: message.replace(FROM_SLACK, ""), slackUrl: match[1] }; + } + } + // The message only repeats the label around a raw Slack channel id. if ( event.type === "slack_channel_bound" || @@ -114,46 +124,51 @@ function parseEvent(event: Event): { return { message }; } -export function IncidentTimeline({ events }: { events: Event[] }) { - if (events.length === 0) return null; +/** One event as a `TimelineItem`; the page composes the `Timeline` around it. */ +export function IncidentTimelineItem({ event }: { event: Event }) { + const config = eventConfig[event.type]; + const { status, severity, message, slackUrl } = parseEvent(event); + const variant = status + ? statusConfig[status].variant + : event.type === "declared" && severity + ? severityConfig[severity].variant + : config.variant; return ( - - {events.map((event) => { - const config = eventConfig[event.type]; - const { status, severity, message } = parseEvent(event); - const variant = status - ? statusConfig[status].variant - : event.type === "declared" && severity - ? severityConfig[severity].variant - : config.variant; - return ( - - - - - - - - {config.label} - {status ? : null} - {severity ? ( - - ) : null} - - {personName(event.createdByUser) ?? "System"} - - - - - {message ? ( - - - - ) : null} - - - ); - })} - + + + + + + + + {config.label} + {status ? : null} + {severity ? : null} + + {personName(event.createdByUser) ?? "System"} + + {slackUrl ? ( + + + + via Slack + + + ) : null} + + + + {message ? ( + + + + ) : null} + + ); } diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts index 48ec4b65..af5a948c 100644 --- a/packages/services/src/incident/list.ts +++ b/packages/services/src/incident/list.ts @@ -12,6 +12,7 @@ const userColumns = { firstName: true, lastName: true, email: true, + photoUrl: true, } as const; const statusOrder = sql`case ${incident.status} when 'open' then 0 when 'mitigated' then 1 when 'resolved' then 2 else 3 end`;