Skip to content

Commit c1a91ef

Browse files
committed
Add pod lifecycle rail
1 parent 36f8a41 commit c1a91ef

4 files changed

Lines changed: 288 additions & 53 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Activity, History, RotateCw } from "lucide-react";
2+
import { containerCurrentState, containerLastState, containerLifecycleTone, currentStateTime, lastStateTime } from "../lib/podLifecycle";
3+
import type { ContainerDetails, ResourceDetails } from "../types/kube";
4+
import { StatusDot } from "./status";
5+
6+
type LifecycleItem = {
7+
container: ContainerDetails;
8+
current: string;
9+
currentTime: string;
10+
last: string;
11+
lastTime: string;
12+
tone: ReturnType<typeof containerLifecycleTone>;
13+
};
14+
15+
export function PodLifecycleRail({ details }: { details: ResourceDetails }) {
16+
const containers = details.pod?.containers ?? [];
17+
const items = containers.map(containerLifecycleItem);
18+
19+
if (!shouldShowLifecycleRail(items)) {
20+
return null;
21+
}
22+
23+
const readyCount = containers.filter((container) => container.ready).length;
24+
const restartCount = containers.reduce((sum, container) => sum + container.restartCount, 0);
25+
26+
return (
27+
<section className="pod-lifecycle-rail" aria-label="Container lifecycle">
28+
<header>
29+
<span>Lifecycle</span>
30+
<strong>{readyCount}/{containers.length} ready</strong>
31+
<small>{restartCount} restarts</small>
32+
</header>
33+
<div>
34+
{items.map((item) => (
35+
<article className={item.tone} key={item.container.name}>
36+
<div>
37+
<StatusDot state={item.tone} />
38+
<strong title={item.container.name}>{item.container.name}</strong>
39+
<small>{item.container.role}</small>
40+
</div>
41+
<LifecycleFact icon={Activity} label="Now" value={item.current || "pending"} meta={item.currentTime || "live"} />
42+
{item.last ? <LifecycleFact icon={History} label="Last" value={item.last} meta={item.lastTime || "previous"} /> : null}
43+
<LifecycleFact icon={RotateCw} label="Restarts" value={String(item.container.restartCount)} meta={item.container.ready ? "ready" : "not ready"} />
44+
</article>
45+
))}
46+
</div>
47+
</section>
48+
);
49+
}
50+
51+
function containerLifecycleItem(container: ContainerDetails): LifecycleItem {
52+
return {
53+
container,
54+
current: containerCurrentState(container),
55+
currentTime: currentStateTime(container),
56+
last: containerLastState(container),
57+
lastTime: lastStateTime(container),
58+
tone: containerLifecycleTone(container),
59+
};
60+
}
61+
62+
function shouldShowLifecycleRail(items: LifecycleItem[]) {
63+
return items.length > 1 || items.some((item) => item.tone !== "healthy" || item.container.restartCount > 0 || item.last);
64+
}
65+
66+
function LifecycleFact({
67+
icon: Icon,
68+
label,
69+
meta,
70+
value,
71+
}: {
72+
icon: typeof Activity;
73+
label: string;
74+
meta: string;
75+
value: string;
76+
}) {
77+
return (
78+
<span className="pod-lifecycle-fact">
79+
<Icon size={13} />
80+
<small>{label}</small>
81+
<strong title={value}>{value}</strong>
82+
<em title={meta}>{meta}</em>
83+
</span>
84+
);
85+
}

src/components/ResourceDetail.tsx

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
22
import { Activity, ArrowLeft, Box, CheckCircle2, FileText, GitCommitHorizontal, ImageIcon, Network, RotateCw, ShieldAlert, Skull, Star, TerminalSquare } from "lucide-react";
3+
import { containerCurrentState, containerLastState, currentStateTime, lastStateTime } from "../lib/podLifecycle";
34
import { matchesSelector, ownsPod, referencesResource, workloadKinds } from "../lib/resourceRelationships";
45
import type { ContainerDetails, HealthState, PodActionResult, PodCondition, ResourceDetails, ResourceRow } from "../types/kube";
56
import { PodEventRail } from "./PodEventRail";
67
import { PodIssueStrip } from "./PodIssueStrip";
8+
import { PodLifecycleRail } from "./PodLifecycleRail";
79
import { PodLinkStrip } from "./PodLinkStrip";
810
import { PodPlacementStrip } from "./PodPlacementStrip";
911
import { PodTerminal, type LogMode } from "./PodTerminal";
@@ -115,6 +117,7 @@ export function ResourceDetail({
115117
<>
116118
<PodIssueStrip details={details} resource={resource} onOpenPreviousLogs={() => openLogs("previous")} />
117119
<PodStatusPanel details={details} resource={resource} />
120+
<PodLifecycleRail details={details} />
118121
<PodPlacementStrip pod={details.pod} />
119122
<PodLinkStrip
120123
allResources={allResources}
@@ -502,59 +505,6 @@ function ContainerStateFact({
502505
);
503506
}
504507

505-
function containerCurrentState(container: ContainerDetails) {
506-
const parts = [
507-
container.state,
508-
container.reason,
509-
container.exitCode == null ? "" : `exit ${container.exitCode}`,
510-
].filter(Boolean);
511-
512-
return parts.join(" / ");
513-
}
514-
515-
function containerLastState(container: ContainerDetails) {
516-
const parts = [
517-
container.lastReason,
518-
container.lastExitCode == null ? "" : `exit ${container.lastExitCode}`,
519-
].filter(Boolean);
520-
521-
return parts.join(" / ");
522-
}
523-
524-
function currentStateTime(container: ContainerDetails) {
525-
if (container.startedAt) {
526-
return `since ${formatLifecycleTime(container.startedAt)}`;
527-
}
528-
if (container.finishedAt) {
529-
return `ended ${formatLifecycleTime(container.finishedAt)}`;
530-
}
531-
return "";
532-
}
533-
534-
function lastStateTime(container: ContainerDetails) {
535-
if (container.lastFinishedAt) {
536-
return `ended ${formatLifecycleTime(container.lastFinishedAt)}`;
537-
}
538-
if (container.lastStartedAt) {
539-
return `started ${formatLifecycleTime(container.lastStartedAt)}`;
540-
}
541-
return "";
542-
}
543-
544-
function formatLifecycleTime(value: string) {
545-
const timestamp = Date.parse(value);
546-
if (Number.isNaN(timestamp)) {
547-
return value.replace("T", " ").replace("Z", "");
548-
}
549-
550-
return new Intl.DateTimeFormat(undefined, {
551-
month: "short",
552-
day: "numeric",
553-
hour: "2-digit",
554-
minute: "2-digit",
555-
}).format(timestamp);
556-
}
557-
558508
function ActionResult({
559509
onConfirm,
560510
resource,

src/lib/podLifecycle.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ContainerDetails, HealthState } from "../types/kube";
2+
3+
const criticalLifecyclePattern = /(crashloop|imagepull|errimage|invalidimage|oomkilled|runcontainer|createcontainer|failed|error|exit)/i;
4+
5+
export function containerCurrentState(container: ContainerDetails) {
6+
const parts = [
7+
container.state,
8+
container.reason,
9+
container.exitCode == null ? "" : `exit ${container.exitCode}`,
10+
].filter(Boolean);
11+
12+
return parts.join(" / ");
13+
}
14+
15+
export function containerLastState(container: ContainerDetails) {
16+
const parts = [
17+
container.lastReason,
18+
container.lastExitCode == null ? "" : `exit ${container.lastExitCode}`,
19+
].filter(Boolean);
20+
21+
return parts.join(" / ");
22+
}
23+
24+
export function currentStateTime(container: ContainerDetails) {
25+
if (container.startedAt) {
26+
return `since ${formatLifecycleTime(container.startedAt)}`;
27+
}
28+
if (container.finishedAt) {
29+
return `ended ${formatLifecycleTime(container.finishedAt)}`;
30+
}
31+
return "";
32+
}
33+
34+
export function lastStateTime(container: ContainerDetails) {
35+
if (container.lastFinishedAt) {
36+
return `ended ${formatLifecycleTime(container.lastFinishedAt)}`;
37+
}
38+
if (container.lastStartedAt) {
39+
return `started ${formatLifecycleTime(container.lastStartedAt)}`;
40+
}
41+
return "";
42+
}
43+
44+
export function containerLifecycleTone(container: ContainerDetails): Exclude<HealthState, "syncing"> {
45+
const diagnostic = [
46+
container.state,
47+
container.reason,
48+
container.message,
49+
container.lastReason,
50+
container.exitCode == null ? "" : `exit ${container.exitCode}`,
51+
container.lastExitCode == null ? "" : `exit ${container.lastExitCode}`,
52+
].join(" ");
53+
54+
if (!container.ready && criticalLifecyclePattern.test(diagnostic)) {
55+
return "critical";
56+
}
57+
if (!container.ready || container.restartCount > 0 || container.lastReason || container.lastExitCode != null) {
58+
return "warning";
59+
}
60+
return "healthy";
61+
}
62+
63+
function formatLifecycleTime(value: string) {
64+
const timestamp = Date.parse(value);
65+
if (Number.isNaN(timestamp)) {
66+
return value.replace("T", " ").replace("Z", "");
67+
}
68+
69+
return new Intl.DateTimeFormat(undefined, {
70+
month: "short",
71+
day: "numeric",
72+
hour: "2-digit",
73+
minute: "2-digit",
74+
}).format(timestamp);
75+
}

src/styles/base.css

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,6 +1536,131 @@ summary:focus-visible {
15361536
box-shadow: inset 0 -2px 0 var(--green);
15371537
}
15381538

1539+
.pod-lifecycle-rail {
1540+
display: grid;
1541+
grid-template-columns: 150px minmax(0, 1fr);
1542+
border: 1px solid var(--line);
1543+
background: var(--line);
1544+
}
1545+
1546+
.pod-lifecycle-rail header {
1547+
display: grid;
1548+
align-content: center;
1549+
gap: 5px;
1550+
min-block-size: 92px;
1551+
padding-inline: 14px;
1552+
background: var(--surface);
1553+
}
1554+
1555+
.pod-lifecycle-rail header span,
1556+
.pod-lifecycle-rail header strong,
1557+
.pod-lifecycle-rail header small,
1558+
.pod-lifecycle-rail article small,
1559+
.pod-lifecycle-rail article em {
1560+
color: var(--text-soft);
1561+
font-family: var(--font-mono);
1562+
font-size: 11px;
1563+
letter-spacing: 0.04em;
1564+
text-transform: uppercase;
1565+
}
1566+
1567+
.pod-lifecycle-rail header strong {
1568+
color: var(--text);
1569+
}
1570+
1571+
.pod-lifecycle-rail header small {
1572+
color: var(--muted);
1573+
}
1574+
1575+
.pod-lifecycle-rail > div {
1576+
display: grid;
1577+
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
1578+
gap: 1px;
1579+
min-inline-size: 0;
1580+
}
1581+
1582+
.pod-lifecycle-rail article {
1583+
display: grid;
1584+
gap: 9px;
1585+
min-inline-size: 0;
1586+
min-block-size: 92px;
1587+
padding: 11px 12px;
1588+
background: var(--surface);
1589+
}
1590+
1591+
.pod-lifecycle-rail article.warning,
1592+
.pod-lifecycle-rail article.critical {
1593+
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--orange) 62%, transparent);
1594+
}
1595+
1596+
.pod-lifecycle-rail article > div {
1597+
display: flex;
1598+
gap: 7px;
1599+
align-items: center;
1600+
min-inline-size: 0;
1601+
}
1602+
1603+
.pod-lifecycle-rail article > div strong {
1604+
min-inline-size: 0;
1605+
overflow: hidden;
1606+
color: var(--text);
1607+
font-size: 13px;
1608+
text-overflow: ellipsis;
1609+
white-space: nowrap;
1610+
}
1611+
1612+
.pod-lifecycle-rail article > div small {
1613+
margin-inline-start: auto;
1614+
padding: 2px 5px;
1615+
border: 1px solid var(--line);
1616+
color: var(--muted);
1617+
background: var(--surface-soft);
1618+
}
1619+
1620+
.pod-lifecycle-fact {
1621+
display: grid;
1622+
grid-template-columns: 14px 62px minmax(0, 1fr) auto;
1623+
gap: 6px;
1624+
align-items: center;
1625+
min-inline-size: 0;
1626+
}
1627+
1628+
.pod-lifecycle-fact svg {
1629+
color: var(--green-deep);
1630+
}
1631+
1632+
.pod-lifecycle-rail article.warning .pod-lifecycle-fact svg,
1633+
.pod-lifecycle-rail article.critical .pod-lifecycle-fact svg {
1634+
color: var(--orange);
1635+
}
1636+
1637+
.pod-lifecycle-fact strong,
1638+
.pod-lifecycle-fact em {
1639+
min-inline-size: 0;
1640+
overflow: hidden;
1641+
text-overflow: ellipsis;
1642+
white-space: nowrap;
1643+
}
1644+
1645+
.pod-lifecycle-fact small {
1646+
color: var(--muted);
1647+
font-size: 10px;
1648+
}
1649+
1650+
.pod-lifecycle-fact strong {
1651+
color: var(--text);
1652+
font-family: var(--font-mono);
1653+
font-size: 11px;
1654+
letter-spacing: 0;
1655+
text-transform: none;
1656+
}
1657+
1658+
.pod-lifecycle-fact em {
1659+
color: var(--muted);
1660+
font-style: normal;
1661+
font-size: 10px;
1662+
}
1663+
15391664
.pod-placement-strip {
15401665
display: grid;
15411666
grid-template-columns: 150px minmax(0, 1fr);

0 commit comments

Comments
 (0)