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`;