Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/desktop/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { defineConfig } from "@playwright/test";

// The preview port is overridable so a local service squatting on the
// default (e.g. a manual `python -m http.server` previewing apps/landing)
// cannot silently win reuseExistingServer and serve the wrong app.
const port = Number(process.env.ASTER_SMOKE_PORT || 4173);

export default defineConfig({
testDir: "./tests",
testMatch: "renderer-smoke.spec.ts",
timeout: 60_000,
use: {
baseURL: "http://127.0.0.1:4173",
baseURL: `http://127.0.0.1:${port}`,
},
expect: {
toHaveScreenshot: {
pathTemplate: "{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}",
},
},
webServer: {
command: "pnpm build:renderer && pnpm vite preview --host 127.0.0.1 --port 4173 --strictPort",
url: "http://127.0.0.1:4173",
command: `pnpm build:renderer && pnpm vite preview --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
Expand Down
154 changes: 121 additions & 33 deletions apps/desktop/src/renderer/App.tsx

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions apps/desktop/src/renderer/components/ResourceTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export function rowKey(row: ResourceRow): string {
return row.uid || `${row.namespace}/${row.name}`;
}

/**
* Identity of a pod's usage entry: namespace/name rather than uid, because
* metrics.k8s.io responses carry no uid and the same pod name in two
* selected namespaces must not trade usage rows.
*/
export function podUsageKey(namespace: string, name: string): string {
return `${namespace}/${name}`;
}

export function ResourceTable({ rows, selected, checkedRows, onToggleRow, onToggleAll, hasMore, loadingMore, onLoadMore, loading, error, onSelect, podMetrics }: {
rows: ResourceRow[];
selected?: ResourceRow;
Expand All @@ -25,7 +34,7 @@ export function ResourceTable({ rows, selected, checkedRows, onToggleRow, onTogg
loading: boolean;
error: string;
onSelect(row: ResourceRow): void;
/** Live CPU/memory totals keyed by pod name; present only for the Pod kind. */
/** Live CPU/memory totals keyed by namespace/name; present only for the Pod kind. */
podMetrics?: ReadonlyMap<string, { cpu?: string; memory?: string }>;
}) {
const viewport = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -147,7 +156,7 @@ export function ResourceTable({ rows, selected, checkedRows, onToggleRow, onTogg
const row = rows[virtualRow.index];
const active = selected?.uid === row.uid;
const checked = checkedRows.has(rowKey(row));
const usage = podMetrics?.get(row.name);
const usage = podMetrics?.get(podUsageKey(row.namespace, row.name));
return (
<div
role="row"
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/renderer/detail/DetailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import {
import type { ResourceRow } from "../../shared/types";
import { findCatalogIcon } from "../lib/resource-catalog";
import type { ResourceActionDescriptor, ResourceActionId } from "./resource-actions";
import type { RolloutStatus } from "./workload-detail";

export interface DetailHeaderProps {
row: ResourceRow;
/** Live rollout state for workload kinds; absent when there is nothing to say. */
rollout?: RolloutStatus;
actions: ResourceActionDescriptor[];
canMutate: boolean;
mutationBusy: boolean;
Expand All @@ -31,6 +34,7 @@ export interface DetailHeaderProps {
*/
export function DetailHeader({
row,
rollout,
actions,
canMutate,
mutationBusy,
Expand Down Expand Up @@ -59,6 +63,7 @@ export function DetailHeader({
<div className="resource-detail-title-row">
<h1 title={row.name}>{row.name}</h1>
<StatusBadge status={row.status} deleting={row.deleting} />
{rollout && <RolloutBadge rollout={rollout} />}
</div>
</div>

Expand Down Expand Up @@ -181,3 +186,34 @@ export function StatusBadge({ status, deleting }: { status?: string; deleting?:
</Badge>
);
}

/** One row per rollout state: label, badge tone, and icon travel together. */
const ROLLOUT_BADGE: Record<RolloutStatus["state"], {
label: string;
variant: "destructive" | "secondary" | "outline";
Icon: typeof Clock3;
}> = {
progressing: { label: "Rollout in progress", variant: "outline", Icon: Clock3 },
complete: { label: "Rolled out", variant: "secondary", Icon: CheckCircle2 },
stuck: { label: "Rollout stuck", variant: "destructive", Icon: AlertCircle },
};

/**
* The rollout chip: distinguishes "rolling out" from "rolled out" and
* "stuck" so live updates have somewhere to land at a glance. Tone rides
* with text, never color alone.
*/
function RolloutBadge({ rollout }: { rollout: RolloutStatus }) {
const { label, variant, Icon } = ROLLOUT_BADGE[rollout.state];
return (
<Badge
className="resource-status-badge"
variant={variant}
title={rollout.message}
data-testid="rollout-status"
>
<Icon aria-hidden="true" />
{label}
</Badge>
);
}
49 changes: 44 additions & 5 deletions apps/desktop/src/renderer/detail/ResourceDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"
import { usePodMetrics } from "../hooks/usePodMetrics";
import { useResourceList } from "../hooks/useResourceList";
import { findEnabledResourceKind } from "../lib/resource-catalog";
import { newestEventsFirst } from "../lib/resource-events";
import { DetailHeader } from "./DetailHeader";
import { LogViewer } from "./LogViewer";
import { MutationDiffView } from "./MutationDiffView";
Expand All @@ -43,8 +44,9 @@ import { extractForwardPorts } from "./port-forward-ports";
import { resourceActionsFor, type ResourceActionId } from "./resource-actions";
import { formatTimestamp } from "./resource-format";
import { HighlightedYaml } from "./yaml-highlight";
import { parseWorkloadDetails, podSelector } from "./workload-detail";
import { parseWorkloadDetails, podSelector, rolloutStatus } from "./workload-detail";
import { WorkloadPodsPanel } from "./WorkloadPodsPanel";
import { usePodEvents } from "../hooks/usePodEvents";

type MutationDraft = Omit<
ResourceMutationRequest,
Expand Down Expand Up @@ -171,7 +173,7 @@ export function ResourceDetailView({
const pods = useResourceList({
contextId,
kind: POD_KIND,
namespace: row?.namespace ?? "",
namespaceScope: row?.namespace ? [row.namespace] : [],
coreReady,
setError: setPodsError,
labelSelector: selector,
Expand All @@ -193,6 +195,19 @@ export function ResourceDetailView({
isPod ? row?.namespace ?? "" : "",
isPod ? row?.name ?? "" : "",
);
// The rollout's child pod events (Pulling / Created / Started / Killing),
// joined to this workload's own pods; one polled list request, never a
// watch stream or a per-pod fan-out.
const podNames = useMemo(() => pods.visibleRows.map((pod) => pod.name), [pods.visibleRows]);
const podEvents = usePodEvents({
contextId,
namespace: row?.namespace ?? "",
names: podNames,
enabled: Boolean(workload && selector),
});
// The Events surface merges the object's own events (ScalingReplicaSet)
// with its pods' events, newest first, so a rollout reads as one timeline.
const allEvents = useMemo(() => mergeEvents(events, podEvents), [events, podEvents]);

if (!row) {
return (
Expand All @@ -213,6 +228,9 @@ export function ResourceDetailView({
const actions = resourceActionsFor(currentRow.kind);
const actionIds = new Set(actions.map((action) => action.id));
const showLogs = currentRow.kind === "Pod" || isWorkloadLogKind(currentRow.kind);
// At-a-glance rollout state (in progress / complete / stuck) from the live
// Progressing condition plus the replica counters.
const rollout = rolloutStatus(details, currentRow);
const podsLoading = (!detail && !detailError) || pods.loading;
const podsFailure = detailError || podsError;
const podsPreview: PodsPreview | undefined = !workload ? undefined : {
Expand Down Expand Up @@ -276,6 +294,7 @@ export function ResourceDetailView({
<section className="resource-detail-view" data-testid="resource-detail-view">
<DetailHeader
row={currentRow}
rollout={rollout}
actions={actions}
canMutate={canMutate}
mutationBusy={mutationBusy}
Expand All @@ -299,7 +318,7 @@ export function ResourceDetailView({
{showLogs && <TabsTrigger value="logs">Logs</TabsTrigger>}
<TabsTrigger value="yaml">YAML</TabsTrigger>
<TabsTrigger value="events">
Events{events.length ? ` (${events.length})` : ""}
Events{allEvents.length ? ` (${allEvents.length})` : ""}
</TabsTrigger>
<TabsTrigger value="related">
Related{related.length ? ` (${related.length})` : ""}
Expand All @@ -312,7 +331,7 @@ export function ResourceDetailView({
row={currentRow}
details={details}
journal={journal}
events={events}
events={allEvents}
related={related}
pods={podsPreview}
metrics={isPod ? metrics : undefined}
Expand Down Expand Up @@ -375,7 +394,7 @@ export function ResourceDetailView({
</TabsContent>

<TabsContent value="events" className="resource-detail-padded-tab">
<EventsView events={events} />
<EventsView events={allEvents} />
</TabsContent>

<TabsContent value="related" className="resource-detail-padded-tab">
Expand Down Expand Up @@ -635,6 +654,26 @@ function EmptyTab({ icon, title, detail }: { icon: React.ReactNode; title: strin
return <div className="resource-tab-empty">{icon}<h2>{title}</h2><p>{detail}</p></div>;
}

/** Bound of the merged events timeline; matches the events one-shot's page cap. */
const MERGED_EVENT_LIMIT = 100;

/**
* Merges the open object's own events with its child pods' events, newest
* first. The object's one-shot fetch and the pod-events poll replace their
* halves wholesale, so a plain dedupe-and-sort keeps the timeline stable.
*/
function mergeEvents(objectEvents: ResourceEvent[], podEvents: ResourceEvent[]): ResourceEvent[] {
const seen = new Set<string>();
const deduped: ResourceEvent[] = [];
for (const event of [...objectEvents, ...podEvents]) {
const key = `${event.namespace}/${event.name}|${event.reason || ""}|${event.lastTimestamp || ""}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(event);
}
return newestEventsFirst(deduped, MERGED_EVENT_LIMIT);
}

function OperationInputDialog({
kind,
value,
Expand Down
123 changes: 122 additions & 1 deletion apps/desktop/src/renderer/detail/workload-detail.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseWorkloadDetails, podSelector } from "./workload-detail";
import { parseWorkloadDetails, podSelector, rolloutStatus } from "./workload-detail";

const DEPLOYMENT = `
apiVersion: apps/v1
Expand Down Expand Up @@ -143,3 +143,124 @@ describe("podSelector", () => {
expect(podSelector(parseWorkloadDetails(DEPLOYMENT))).toBe("app=brain-ui,tier=web");
});
});

describe("rolloutStatus", () => {
const details = parseWorkloadDetails(DEPLOYMENT)!;

it("Progressing=True with NewReplicaSetAvailable is complete", () => {
const progressing = {
type: "Progressing",
status: "True",
reason: "NewReplicaSetAvailable",
message: "Deployment has minimum availability.",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [progressing] };
expect(withCondition.conditions).toHaveLength(1);
expect(rolloutStatus(withCondition, { desired: 2, ready: 2, updated: 2 })).toEqual({
state: "complete",
message: "NewReplicaSetAvailable · 2/2 ready · 2 updated",
});
});

it("Progressing=True mid-rollout is progressing with the replica counters", () => {
const progressing = {
type: "Progressing",
status: "True",
reason: "ReplicaSetUpdated",
message: "ReplicaSet is progressing.",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [progressing] };
expect(withCondition.conditions).toHaveLength(1);
expect(rolloutStatus(withCondition, { desired: 5, ready: 2, updated: 2 })).toEqual({
state: "progressing",
message: "ReplicaSetUpdated · 2/5 ready · 2 updated",
});
});

it("Progressing=False is stuck (progress deadline exceeded)", () => {
const failing = {
type: "Progressing",
status: "False",
reason: "ProgressDeadlineExceeded",
message: "Progress deadline exceeded.",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [failing] };
expect(rolloutStatus(withCondition, { desired: 2, ready: 1, updated: 1 })).toEqual({
state: "stuck",
message: "ProgressDeadlineExceeded · 1/2 ready · 1 updated",
});
});

it("settled counters complete a non-terminal Progressing=True (StatefulSet)", () => {
// StatefulSet keeps Progressing=True with a reason like SuccessfulCreate
// long after the rollout settles; only the counters can say it is done.
const progressing = {
type: "Progressing",
status: "True",
reason: "SuccessfulCreate",
message: "create Pod db-0 in StatefulSet db successful",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [progressing] };
expect(rolloutStatus(withCondition, { desired: 3, ready: 3, updated: 3 })).toEqual({
state: "complete",
message: "3/3 ready · 3 updated",
});
});

it("lagging updated replicas keep a settled-ready rollout progressing", () => {
// Mid-rollout with maxUnavailable=0, ready can sit at desired while the
// new revision is still rolling: updated < desired must override.
const progressing = {
type: "Progressing",
status: "True",
reason: "ReplicaSetUpdated",
message: "ReplicaSet is progressing.",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [progressing] };
expect(rolloutStatus(withCondition, { desired: 3, ready: 3, updated: 1 })).toEqual({
state: "progressing",
message: "ReplicaSetUpdated · 3/3 ready · 1 updated",
});
});

it("unknown counters fall back to the condition alone", () => {
const progressing = {
type: "Progressing",
status: "True",
reason: "SuccessfulCreate",
message: "create Pod db-0 in StatefulSet db successful",
lastTransitionTime: "2026-08-12T13:49:39Z",
};
const withCondition = { ...details, conditions: [progressing] };
expect(rolloutStatus(withCondition, { desired: undefined, ready: undefined })).toEqual({
state: "progressing",
message: "SuccessfulCreate",
});
});

it("without a condition, lagging replicas mean progressing", () => {
const none = { ...details, conditions: [] };
expect(rolloutStatus(none, { desired: 3, ready: 1 })).toEqual({
state: "progressing",
message: "1/3 ready",
});
});

it("without a condition, fully ready replicas mean complete", () => {
const none = { ...details, conditions: [] };
expect(rolloutStatus(none, { desired: 3, ready: 3, updated: 3 })).toEqual({
state: "complete",
message: "3/3 ready · 3 updated",
});
});

it("objects without rollout semantics return undefined", () => {
expect(rolloutStatus(undefined, {})).toBeUndefined();
expect(rolloutStatus({ ...details, conditions: [] }, { desired: undefined, ready: undefined })).toBeUndefined();
});
});
Loading