Skip to content

Commit 5e8e10c

Browse files
committed
Add related event navigation
1 parent 9be3b6d commit 5e8e10c

5 files changed

Lines changed: 218 additions & 8 deletions

File tree

src/components/PodEventRail.tsx

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
1+
import { useMemo } from "react";
12
import { AlertTriangle, Clock3 } from "lucide-react";
2-
import type { ResourceDetails, ResourceEvent } from "../types/kube";
3+
import { compareEventResources, relatedEventResourcesFor } from "../lib/resourceEvents";
4+
import type { ResourceDetails, ResourceEvent, ResourceRow } from "../types/kube";
35

46
export function PodEventRail({
57
details,
68
detailsError,
79
detailsLoading,
10+
onOpenResource,
11+
resource,
12+
resources = [],
813
}: {
914
details: ResourceDetails;
1015
detailsError: string;
1116
detailsLoading: boolean;
17+
onOpenResource?: (id: string, intent?: "logs" | null) => void;
18+
resource?: ResourceRow;
19+
resources?: ResourceRow[];
1220
}) {
1321
const { events, warningCount } = eventRailView(details.events);
22+
const eventResources = useMemo(
23+
() => resource ? relatedEventResourcesFor(resource, resources).sort(compareEventResources) : [],
24+
[resource, resources],
25+
);
26+
const linkedEvents = linkEventResources(events, eventResources);
1427

1528
return (
1629
<section className="pod-event-rail" aria-label="Pod event timeline">
@@ -35,8 +48,15 @@ export function PodEventRail({
3548
<div className="pod-event-list">
3649
{detailsLoading ? (
3750
<PodEventEmpty label="Syncing events" message="Waiting for Kubernetes event data." />
38-
) : events.length ? (
39-
events.map((event, index) => <PodEventItem event={event} key={`${event.reason}-${event.age}-${index}`} />)
51+
) : linkedEvents.length ? (
52+
linkedEvents.map(({ event, eventResource }, index) => (
53+
<PodEventItem
54+
event={event}
55+
eventResource={eventResource}
56+
key={`${event.reason}-${event.age}-${index}`}
57+
onOpenResource={onOpenResource}
58+
/>
59+
))
4060
) : (
4161
<PodEventEmpty label="No pod events" message={detailsError || "No warning or normal events returned."} />
4262
)}
@@ -45,12 +65,19 @@ export function PodEventRail({
4565
);
4666
}
4767

48-
function PodEventItem({ event }: { event: ResourceEvent }) {
68+
function PodEventItem({
69+
event,
70+
eventResource,
71+
onOpenResource,
72+
}: {
73+
event: ResourceEvent;
74+
eventResource?: ResourceRow;
75+
onOpenResource?: (id: string, intent?: "logs" | null) => void;
76+
}) {
4977
const warning = isWarningEvent(event);
5078
const count = eventCount(event);
51-
52-
return (
53-
<article className={warning ? "pod-event-item warning" : "pod-event-item"}>
79+
const content = (
80+
<>
5481
<i aria-hidden="true" />
5582
<div>
5683
<span>{event.type || "Normal"}</span>
@@ -61,6 +88,25 @@ function PodEventItem({ event }: { event: ResourceEvent }) {
6188
</div>
6289
<p>{event.message || event.age || "Event recorded."}</p>
6390
<time>{event.age || "live"}</time>
91+
</>
92+
);
93+
94+
if (eventResource && onOpenResource) {
95+
return (
96+
<button
97+
className={warning ? "pod-event-item warning linked" : "pod-event-item linked"}
98+
title={`Open Event ${eventResource.name}`}
99+
type="button"
100+
onClick={() => onOpenResource(eventResource.id)}
101+
>
102+
{content}
103+
</button>
104+
);
105+
}
106+
107+
return (
108+
<article className={warning ? "pod-event-item warning" : "pod-event-item"}>
109+
{content}
64110
</article>
65111
);
66112
}
@@ -101,3 +147,24 @@ function isWarningEvent(event: ResourceEvent) {
101147
function eventCount(event: ResourceEvent) {
102148
return Number.isFinite(event.count) && event.count > 0 ? event.count : 1;
103149
}
150+
151+
function linkEventResources(events: ResourceEvent[], eventResources: ResourceRow[]) {
152+
const unusedResources = [...eventResources];
153+
154+
return events.map((event) => {
155+
const resourceIndex = unusedResources.findIndex((resource) =>
156+
eventResourceType(resource).toLowerCase() === event.type.toLowerCase() &&
157+
resource.diagnostic === event.reason
158+
);
159+
const eventResource = resourceIndex >= 0 ? unusedResources.splice(resourceIndex, 1)[0] : undefined;
160+
161+
return { event, eventResource };
162+
});
163+
}
164+
165+
function eventResourceType(resource: ResourceRow) {
166+
if (resource.image) {
167+
return resource.image;
168+
}
169+
return resource.status === "warning" ? "Warning" : "Normal";
170+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useMemo } from "react";
2+
import { Clock3 } from "lucide-react";
3+
import { compareEventResources, eventResourceTone, formatEventResourceAge, relatedEventResourcesFor } from "../lib/resourceEvents";
4+
import type { ResourceRow } from "../types/kube";
5+
import { StatusDot } from "./status";
6+
7+
type RelatedEventRailProps = {
8+
onOpenResource: (id: string, intent?: "logs" | null) => void;
9+
resource: ResourceRow;
10+
resources: ResourceRow[];
11+
};
12+
13+
export function RelatedEventRail({ onOpenResource, resource, resources }: RelatedEventRailProps) {
14+
const events = useMemo(() => relatedEventResourcesFor(resource, resources).sort(compareEventResources), [resource, resources]);
15+
16+
if (resource.kind === "Pod" || resource.kind === "Event" || !events.length) {
17+
return null;
18+
}
19+
20+
const visibleEvents = events.slice(0, 5);
21+
const warningCount = events.filter((event) => event.status !== "healthy").length;
22+
const tone = eventResourceTone(events);
23+
24+
return (
25+
<section className={`workload-pod-rail service-backend-rail related-event-rail ${tone}`} aria-label="Related events">
26+
<header>
27+
<span>
28+
<Clock3 size={15} />
29+
Events
30+
</span>
31+
<strong>{warningCount ? `${warningCount}/${events.length} warning` : `${events.length} linked`}</strong>
32+
<small>{formatEventResourceAge(events[0]?.age) || resource.namespace}</small>
33+
</header>
34+
<div>
35+
{visibleEvents.map((event) => (
36+
<button className={event.status} key={event.id} type="button" onClick={() => onOpenResource(event.id)}>
37+
<StatusDot state={event.status} />
38+
<strong title={event.diagnostic || event.name}>{event.diagnostic || event.name}</strong>
39+
<em title={event.owner || event.name}>{event.owner || event.name}</em>
40+
<small>{event.image || event.status}</small>
41+
<small title={event.age}>{formatEventResourceAge(event.age)}</small>
42+
</button>
43+
))}
44+
</div>
45+
</section>
46+
);
47+
}

src/components/ResourceDetail.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { PodLifecycleRail } from "./PodLifecycleRail";
1313
import { PodLinkStrip } from "./PodLinkStrip";
1414
import { PodPlacementStrip } from "./PodPlacementStrip";
1515
import { PodTerminal, type LogMode } from "./PodTerminal";
16+
import { RelatedEventRail } from "./RelatedEventRail";
1617
import { StatusDot } from "./status";
1718
import { StorageBindingRail } from "./StorageBindingRail";
1819

@@ -207,7 +208,14 @@ export function ResourceDetail({
207208

208209
{isPod ? (
209210
<>
210-
<PodEventRail details={details} detailsError={detailsError} detailsLoading={detailsLoading} />
211+
<PodEventRail
212+
details={details}
213+
detailsError={detailsError}
214+
detailsLoading={detailsLoading}
215+
resource={resource}
216+
resources={allResources}
217+
onOpenResource={onOpenResource}
218+
/>
211219
<PodTerminal
212220
details={details}
213221
detailsError={detailsError}
@@ -230,6 +238,7 @@ export function ResourceDetail({
230238
onOpenResource={onOpenResource}
231239
/>
232240
) : null}
241+
<RelatedEventRail resource={resource} resources={allResources} onOpenResource={onOpenResource} />
233242
<CrdDefinitionRail details={details} detailsLoading={detailsLoading} resource={resource} />
234243
<DependencyConsumerRail resource={resource} resources={allResources} onOpenResource={onOpenResource} />
235244
<NamespaceConstraintRail resource={resource} resources={allResources} onOpenResource={onOpenResource} />

src/lib/resourceEvents.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { referencesResource } from "./resourceRelationships";
2+
import type { HealthState, ResourceRow } from "../types/kube";
3+
4+
export function relatedEventResourcesFor(resource: ResourceRow, resources: ResourceRow[]) {
5+
return resources.filter((item) => item.kind === "Event" && referencesEventTarget(item, resource));
6+
}
7+
8+
export function compareEventResources(left: ResourceRow, right: ResourceRow) {
9+
return eventRank(left.status) - eventRank(right.status) ||
10+
eventTimestamp(right.age) - eventTimestamp(left.age) ||
11+
left.name.localeCompare(right.name);
12+
}
13+
14+
export function eventResourceTone(events: ResourceRow[]): HealthState {
15+
if (events.some((event) => event.status === "critical")) {
16+
return "critical";
17+
}
18+
if (events.some((event) => event.status === "warning")) {
19+
return "warning";
20+
}
21+
if (events.every((event) => event.status === "syncing")) {
22+
return "syncing";
23+
}
24+
return "healthy";
25+
}
26+
27+
export function formatEventResourceAge(age?: string) {
28+
if (!age) {
29+
return "";
30+
}
31+
32+
const timestamp = Date.parse(age);
33+
if (Number.isNaN(timestamp)) {
34+
return age;
35+
}
36+
37+
return new Intl.DateTimeFormat(undefined, {
38+
month: "short",
39+
day: "numeric",
40+
hour: "2-digit",
41+
minute: "2-digit",
42+
}).format(timestamp);
43+
}
44+
45+
function referencesEventTarget(event: ResourceRow, target: ResourceRow) {
46+
return referencesResource(event, target) ||
47+
target.namespace === "cluster" && event.references.some((reference) =>
48+
reference.kind === target.kind &&
49+
reference.name === target.name
50+
);
51+
}
52+
53+
function eventRank(status: HealthState) {
54+
switch (status) {
55+
case "critical":
56+
return 0;
57+
case "warning":
58+
return 1;
59+
case "syncing":
60+
return 2;
61+
case "healthy":
62+
return 3;
63+
}
64+
}
65+
66+
function eventTimestamp(age: string) {
67+
const timestamp = Date.parse(age);
68+
return Number.isNaN(timestamp) ? 0 : timestamp;
69+
}

src/styles/base.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,20 @@ summary:focus-visible {
20152015
border-block-end: 1px solid var(--line);
20162016
}
20172017

2018+
button.pod-event-item {
2019+
inline-size: 100%;
2020+
border-inline: 0;
2021+
border-block-start: 0;
2022+
color: inherit;
2023+
background: transparent;
2024+
text-align: start;
2025+
cursor: pointer;
2026+
}
2027+
2028+
button.pod-event-item:hover {
2029+
background: var(--surface-soft);
2030+
}
2031+
20182032
.pod-event-item:last-child {
20192033
border-block-end: 0;
20202034
}
@@ -2658,6 +2672,10 @@ summary:focus-visible {
26582672
color: var(--green-deep);
26592673
}
26602674

2675+
.related-event-rail header svg {
2676+
color: var(--orange);
2677+
}
2678+
26612679
.network-policy-rail header svg {
26622680
color: var(--orange);
26632681
}

0 commit comments

Comments
 (0)