diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index cd94663..53a2142 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -1,11 +1,16 @@ 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: { @@ -13,8 +18,8 @@ export default defineConfig({ }, }, 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, }, diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 4950189..a86f54b 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -4,7 +4,7 @@ import type { AsterSettings, RelatedResource, ResourceKind, ResourceRow, Sources import { CommandPalette } from "./components/CommandPalette"; import { UpdateNotice } from "./components/UpdateNotice"; import { WelcomeCard } from "./components/WelcomeCard"; -import { ResourceTable, rowKey, TableState } from "./components/ResourceTable"; +import { ResourceTable, podUsageKey, rowKey, TableState } from "./components/ResourceTable"; import { Button } from "@/components/ui/button"; import { AlertDialog, @@ -31,7 +31,11 @@ import { useTheme } from "./hooks/useTheme"; import { useUpdater } from "./hooks/useUpdater"; import { buildCommandItems, objectCommandItems, searchResultItems, type CommandAction } from "./lib/command-palette"; import { messageOf, pluralize } from "./lib/format"; +import { namespaceScopeKey, namespaceScopeSummary } from "./lib/namespace-scope"; +import { SLOW_POLL_MS } from "./lib/poll-cadence"; +import { isRolloutWorkloadKind } from "./detail/workload-detail"; import { customResourceGroups, DEFAULT_KIND, findKindInGroups, flattenResourceGroups, SIDEBAR_RESOURCE_GROUPS } from "./lib/resource-catalog"; +import { mostUsedKindIds, recordKindUsage, toggleFavoriteKind } from "./lib/resource-favorites"; import { Sidebar, type SidebarToolGroup } from "./shell/Sidebar"; import { UnifiedToolbar } from "./shell/UnifiedToolbar"; import { WorkbenchShell } from "./shell/WorkbenchShell"; @@ -70,19 +74,37 @@ export default function App() { }, [contextId, core.state]); const [kind, setKind] = useState(DEFAULT_KIND); const namespaces = useNamespaces(contextId, contexts.contexts, setError); + const { namespaceScope } = namespaces; + // Order-insensitive identity of the scope; reset effects and cache keys + // compare this so [a, b] and [b, a] count as the same scope. + const scopeKey = useMemo(() => namespaceScopeKey(namespaceScope), [namespaceScope]); + // Row-scoped surfaces (create dialog, palette search fallback) only have a + // namespace when the scope names exactly one. + const primaryNamespace = namespaceScope.length === 1 ? namespaceScope[0] : ""; + // The create dialog pre-fills a namespace the user actually chose (#31 + // story 24): the single-selection scope first, else the context's default + // for All/multi scopes; the template's literal "default" remains the last + // resort for contexts without one. + const createNamespace = primaryNamespace + || contexts.contexts.find((item) => item.id === contextId)?.namespace + || ""; const resources = useResourceList({ contextId, kind, - namespace: namespaces.namespace, + namespaceScope, coreReady: core.state === "ready", setError, }); const detail = useResourceDetail({ contextId, kind, - namespace: namespaces.namespace, + scopeKey, generation: resources.generation, items: resources.list.items, + coreReady: core.state === "ready", + // The single-object live channel is a rollout-workload feature (#39); + // every other kind keeps the pre-existing selection-follows-list flow. + live: isRolloutWorkloadKind(kind.kind), }); const diagnostics = useDiagnostics({ contextId, @@ -92,7 +114,7 @@ export default function App() { const mutation = useMutation({ contextId, kind, - namespace: namespaces.namespace, + namespace: primaryNamespace, selected: detail.selected, }); const [overviewActive, setOverviewActive] = useState(false); @@ -104,16 +126,18 @@ export default function App() { const [helmActive, setHelmActive] = useState(false); const helm = useHelm({ contextId, - namespace: namespaces.namespace, + namespaceScope, coreReady: core.state === "ready" && helmActive, }); // Live CPU/memory readouts for the Pod table. Only fetched while the Pod // kind is the active pane and the list is namespace-scoped, so a browse - // through other kinds never touches metrics.k8s.io. + // through other kinds never touches metrics.k8s.io. A multi-namespace scope + // issues one ordinary per-namespace call on the same 15s cadence; a failed + // namespace degrades to "no usage for its rows". const [podUsage, setPodUsage] = useState>(new Map()); const [podUsageError, setPodUsageError] = useState(""); - const podPaneActive = kind.id === "pods" && !overviewActive && !helmActive && Boolean(namespaces.namespace); + const podPaneActive = kind.id === "pods" && !overviewActive && !helmActive && namespaceScope.length > 0; useEffect(() => { if (!podPaneActive || !contextId) { setPodUsage(new Map()); @@ -121,31 +145,61 @@ export default function App() { return; } let active = true; + const scope = namespaceScope; const fetchMetrics = async () => { - try { - const metrics = await desktop.metrics.pods(contextId, namespaces.namespace); - if (!active) return; - setPodUsage(new Map(metrics.map((pod) => { + const results = await Promise.all(scope.map(async (namespace) => { + try { + return { namespace, metrics: await desktop.metrics.pods(contextId, namespace), failed: false as const }; + } catch (cause) { + return { namespace, metrics: [], failed: true as const, message: messageOf(cause) }; + } + })); + if (!active) return; + // Only an outage across the whole scope surfaces as a table error; + // partial failures just leave those rows without usage. + const failures = results.filter((result) => result.failed); + setPodUsageError(failures.length === scope.length && failures.length > 0 ? failures[0].message : ""); + const usage = new Map(); + for (const { namespace, metrics } of results) { + for (const pod of metrics) { const cpu = pod.containers.map((container) => container.cpu).find((value) => value); const memory = pod.containers.map((container) => container.memory).find((value) => value); - return [pod.name, { cpu, memory }]; - }))); - setPodUsageError(""); - } catch (cause) { - if (active) setPodUsageError(messageOf(cause)); + usage.set(podUsageKey(pod.namespace || namespace, pod.name), { cpu, memory }); + } } + setPodUsage(usage); }; void fetchMetrics(); - const timer = setInterval(() => void fetchMetrics(), 15_000); + const timer = setInterval(() => void fetchMetrics(), SLOW_POLL_MS); return () => { active = false; clearInterval(timer); }; - }, [podPaneActive, contextId, namespaces.namespace, kind.id]); + }, [podPaneActive, contextId, namespaceScope, kind.id]); const helmToolGroups: SidebarToolGroup[] = useMemo(() => [{ label: "Helm", items: [{ id: "helm", label: "Releases", icon: Ship }], }], []); + + // Sidebar quick access (#competitor parity): starred kinds and the kinds + // this operator actually opens most. UI preferences like the sidebar fold + // prefs — localStorage, renderer-side, no cluster data. + const [favoriteKinds, setFavoriteKinds] = useState(() => readStoredStringList("aster.sidebar.favoriteKinds")); + const [kindUsage, setKindUsage] = useState>(() => readStoredUsage("aster.sidebar.kindUsage")); + useEffect(() => { + localStorage.setItem("aster.sidebar.favoriteKinds", JSON.stringify(favoriteKinds)); + }, [favoriteKinds]); + useEffect(() => { + localStorage.setItem("aster.sidebar.kindUsage", JSON.stringify(kindUsage)); + }, [kindUsage]); + const toggleFavorite = useCallback((id: string) => { + setFavoriteKinds((current) => toggleFavoriteKind(current, id)); + }, []); + // Starred kinds need no second pointer in Most used; the top three fill it. + const mostUsed = useMemo( + () => mostUsedKindIds(kindUsage, new Set(favoriteKinds), 3), + [kindUsage, favoriteKinds], + ); const searchRef = useRef(null); const [paletteOpen, setPaletteOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -188,7 +242,7 @@ export default function App() { const [checkedKeys, setCheckedKeys] = useState>(new Set()); useEffect(() => { setCheckedKeys(new Set()); - }, [contextId, kind.id, namespaces.namespace]); + }, [contextId, kind.id, scopeKey]); const checkedRows = useMemo( () => resources.visibleRows.filter((row) => checkedKeys.has(rowKey(row))), [resources.visibleRows, checkedKeys], @@ -248,9 +302,11 @@ export default function App() { // Selecting a resource kind always leaves the overview and the Helm pane; // the kind switches only when it differs, otherwise the row selection resets. + // Every open feeds the Most used ranking, whichever surface triggered it. const selectKind = useCallback((next: ResourceKind) => { setOverviewActive(false); setHelmActive(false); + setKindUsage((usage) => recordKindUsage(usage, next.id)); if (next.id === kind.id) { detail.clear(); return; @@ -279,10 +335,12 @@ export default function App() { if (!match) return; const { icon: _icon, label: _label, enabled: _enabled, ...nextKind } = match; const namespace = target.namespace || ""; - if (namespace !== namespaces.namespace) namespaces.setNamespace(namespace); + // Palette/related navigation pins the scope to the one target namespace, + // replacing any multi-selection so the pending row cannot get lost. + if (!(namespaceScope.length === 1 && namespaceScope[0] === namespace)) namespaces.setNamespaceScope(namespace ? [namespace] : []); setPendingSelect({ name: target.name, namespace }); selectKind(nextKind); - }, [resourceGroups, namespaces, selectKind]); + }, [resourceGroups, namespaceScope, namespaces, selectKind]); useEffect(() => { if (!pendingSelect) return; @@ -304,7 +362,7 @@ export default function App() { desktop.resources.search({ contextId, query, - namespace: namespaces.namespace || contexts.activeContext?.namespace || "default", + namespace: primaryNamespace || contexts.activeContext?.namespace || "default", }) .then((items) => { if (active) setSearchResults(items); }) .catch(() => { if (active) setSearchResults([]); }); @@ -313,7 +371,7 @@ export default function App() { active = false; clearTimeout(timer); }; - }, [paletteQuery, paletteOpen, contextId, namespaces.namespace, contexts.activeContext]); + }, [paletteQuery, paletteOpen, contextId, primaryNamespace, contexts.activeContext]); // A stale result set is cleared when the palette closes or the query resets. useEffect(() => { @@ -324,7 +382,7 @@ export default function App() { const target = targetId ? contexts.contexts.find((item) => item.id === targetId) : contexts.chosenContext; if (!target || target.error || core.state !== "ready") return; setError(""); - namespaces.setNamespace(target.namespace || ""); + namespaces.setNamespaceScope(target.namespace ? [target.namespace] : []); contexts.setContextChoice(target.id); void setPortForwardContext(target.id).catch((cause) => setError(messageOf(cause))); contexts.setContextId(target.id); @@ -340,7 +398,7 @@ export default function App() { contexts.setContextQuery(""); void setPortForwardContext("").catch((cause) => setError(messageOf(cause))); contexts.setContextId(""); - namespaces.setNamespace(""); + namespaces.setNamespaceScope([]); resources.setQuery(""); detail.clear(); resources.reset(); @@ -400,14 +458,14 @@ export default function App() { namespaces: namespaces.namespaces, namespacesLoading: namespaces.loading, namespacesTruncated: namespaces.truncated, - activeNamespace: namespaces.namespace, + activeNamespace: primaryNamespace, theme, }); // Linear-style contextual mode: while a row is selected, the palette leads // with object-scoped commands under a "Selected object" group. if (detail.selected) return [...objectCommandItems(detail.selected), ...base]; return base; - }, [core.state, contexts.contexts, contextId, resourceGroups, kind.id, namespaces.namespaces, namespaces.loading, namespaces.namespace, theme, detail.selected]); + }, [core.state, contexts.contexts, contextId, resourceGroups, kind.id, namespaces.namespaces, namespaces.loading, primaryNamespace, theme, detail.selected]); const executePaletteCommand = useCallback((action: CommandAction) => { switch (action.type) { @@ -421,7 +479,8 @@ export default function App() { connectContext(action.contextId); return; case "select-namespace": - namespaces.setNamespace(action.namespace); + // Palette navigation stays deterministic: it replaces any selection. + namespaces.setNamespaceScope([action.namespace]); return; case "set-theme": setTheme(action.theme); @@ -593,6 +652,9 @@ export default function App() { onSelectTool={(toolId) => { if (toolId === "helm") showHelm(); }} + favoriteKindIds={favoriteKinds} + onToggleFavoriteKind={toggleFavorite} + mostUsedKindIds={mostUsed} onShowContexts={showContextPicker} /> )} @@ -602,9 +664,9 @@ export default function App() { namespacesTruncated={namespaces.truncated} namespacesLoading={namespaces.loading} namespacesLoaded={namespaces.loaded} - namespace={namespaces.namespace} + namespaceScope={namespaceScope} onNamespaceOpen={namespaces.load} - onNamespaceChange={namespaces.setNamespace} + onNamespaceScopeChange={namespaces.setNamespaceScope} namespaceDisabled={overviewActive || (!helmActive && !kind.namespaced)} query={helmActive || overviewActive ? "" : resources.query} onQueryChange={helmActive || overviewActive ? () => undefined : resources.setQuery} @@ -639,7 +701,7 @@ export default function App() { {helmActive && (

{pluralize(kind.kind)}

{kind.category} · {contexts.activeContext?.name || "Kubernetes"} - {resources.snapshotOnly ? " · All namespaces (snapshot, refresh to update)" : ""} + {resources.snapshotOnly ? (namespaceScope.length > 1 + ? ` · ${namespaceScope.length} namespaces (snapshot, refresh to update)` + : " · All namespaces (snapshot, refresh to update)") : ""} {resources.revalidating ? " · Refreshing…" : ""}

@@ -802,7 +866,7 @@ export default function App() { open={createOpen} onOpenChange={setCreateOpen} kind={kind} - namespace={namespaces.namespace} + namespace={createNamespace} busy={mutation.mutationBusy} message={mutation.mutationMessage} preview={mutation.mutationPreview} @@ -818,3 +882,27 @@ export default function App() { ); } + +/** Reads a stored string array; corrupt or foreign payloads degrade to empty. */ +function readStoredStringList(key: string): string[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(key) || "[]"); + return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") + ? parsed as string[] + : []; + } catch { + return []; + } +} + +/** Reads a stored string→number counter map; corrupt payloads degrade to empty. */ +function readStoredUsage(key: string): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(key) || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const entries = Object.entries(parsed).filter(([, count]) => typeof count === "number"); + return Object.fromEntries(entries); + } catch { + return {}; + } +} diff --git a/apps/desktop/src/renderer/components/ResourceTable.tsx b/apps/desktop/src/renderer/components/ResourceTable.tsx index 3e4ae10..c9c3d88 100644 --- a/apps/desktop/src/renderer/components/ResourceTable.tsx +++ b/apps/desktop/src/renderer/components/ResourceTable.tsx @@ -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; @@ -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; }) { const viewport = useRef(null); @@ -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 (

{row.name}

+ {rollout && }
@@ -181,3 +186,34 @@ export function StatusBadge({ status, deleting }: { status?: string; deleting?: ); } + +/** One row per rollout state: label, badge tone, and icon travel together. */ +const ROLLOUT_BADGE: Record = { + 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 ( + + + ); +} diff --git a/apps/desktop/src/renderer/detail/ResourceDetailView.tsx b/apps/desktop/src/renderer/detail/ResourceDetailView.tsx index 71c16b5..976cb2c 100644 --- a/apps/desktop/src/renderer/detail/ResourceDetailView.tsx +++ b/apps/desktop/src/renderer/detail/ResourceDetailView.tsx @@ -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"; @@ -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, @@ -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, @@ -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 ( @@ -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 : { @@ -276,6 +294,7 @@ export function ResourceDetailView({
Logs} YAML - Events{events.length ? ` (${events.length})` : ""} + Events{allEvents.length ? ` (${allEvents.length})` : ""} Related{related.length ? ` (${related.length})` : ""} @@ -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} @@ -375,7 +394,7 @@ export function ResourceDetailView({ - + @@ -635,6 +654,26 @@ function EmptyTab({ icon, title, detail }: { icon: React.ReactNode; title: strin return
{icon}

{title}

{detail}

; } +/** 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(); + 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, diff --git a/apps/desktop/src/renderer/detail/workload-detail.test.ts b/apps/desktop/src/renderer/detail/workload-detail.test.ts index 73a2181..dfa3d24 100644 --- a/apps/desktop/src/renderer/detail/workload-detail.test.ts +++ b/apps/desktop/src/renderer/detail/workload-detail.test.ts @@ -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 @@ -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(); + }); +}); diff --git a/apps/desktop/src/renderer/detail/workload-detail.ts b/apps/desktop/src/renderer/detail/workload-detail.ts index e460922..527ce29 100644 --- a/apps/desktop/src/renderer/detail/workload-detail.ts +++ b/apps/desktop/src/renderer/detail/workload-detail.ts @@ -1,4 +1,5 @@ import { parse } from "yaml"; +import type { ResourceRow } from "../../shared/types"; export interface WorkloadCondition { type: string; @@ -106,6 +107,85 @@ export function podSelector(details: WorkloadDetails | undefined): string | unde return details.selector; } +export type RolloutState = "progressing" | "complete" | "stuck"; + +/** + * Kinds whose open detail keeps a single-object live channel (#39): the + * pod-owning controllers with rollout semantics. Everything else — Pods, + * ConfigMaps, cluster-scoped kinds — keeps the pre-existing behavior (the + * list watch bumps the selection; snapshot scopes stay frozen), so no watch + * request ever leaves with an empty namespace. + */ +export function isRolloutWorkloadKind(kind: string): boolean { + return kind === "Deployment" || kind === "StatefulSet" || kind === "DaemonSet" + || kind === "ReplicaSet" || kind === "ReplicationController"; +} + +export interface RolloutStatus { + state: RolloutState; + /** Short human summary, e.g. "2/5 ready · 1 updated" or the condition reason. */ + message: string; +} + +/** + * Derives the rollout state from the Progressing condition plus the replica + * counters, so "in progress / complete / stuck" is visible at a glance while + * a rollout runs. The counters come from the live row, the condition from the + * live YAML — both converge through the detail's single-object watch. + * Counters decide completion whenever they are known: StatefulSet keeps + * Progressing=True with a non-terminal reason long after it settles, so only + * "ready == desired and updated == desired" can mark it complete (and lagging + * counters keep an optimistic condition honest mid-rollout). + * Returns undefined for objects without rollout semantics (no usable + * condition and no replica counters, e.g. Pods, ConfigMaps, Jobs without + * spec.replicas). + */ +export function rolloutStatus( + details: WorkloadDetails | undefined, + row: Pick, +): RolloutStatus | undefined { + const condition = details?.conditions.find((item) => item.type === "Progressing"); + const replicas = replicaSummary(row); + const countersSettled = + row.desired !== undefined && row.ready !== undefined && row.ready === row.desired + && (row.updated === undefined || row.updated === row.desired); + if (condition) { + if (condition.status === "False") { + return { + state: "stuck", + message: [condition.reason || "Progressing=False", replicas].filter(Boolean).join(" · "), + }; + } + if (condition.status === "True" && /NewReplicaSetAvailable/i.test(condition.reason)) { + return { state: "complete", message: [condition.reason, replicas].filter(Boolean).join(" · ") }; + } + if (countersSettled) { + return { state: "complete", message: replicas || "Rolled out" }; + } + return { + state: "progressing", + message: [condition.reason || "Rolling out", replicas].filter(Boolean).join(" · "), + }; + } + // No usable Progressing condition (DaemonSets, pending first status): + // the replica counters alone tell progress from completion. + if (row.desired === undefined || row.ready === undefined) return undefined; + if (row.ready < row.desired) { + return { state: "progressing", message: replicaSummary(row) || "Rolling out" }; + } + if (row.updated !== undefined && row.updated < row.desired) { + return { state: "progressing", message: replicaSummary(row) || "Rolling out" }; + } + return { state: "complete", message: replicaSummary(row) || "Ready" }; +} + +function replicaSummary(row: Pick): string { + if (row.desired === undefined || row.ready === undefined) return ""; + const parts = [`${row.ready}/${row.desired} ready`]; + if (row.updated !== undefined) parts.push(`${row.updated} updated`); + return parts.join(" · "); +} + function parseConditions(value: unknown): WorkloadCondition[] { if (!Array.isArray(value)) return []; return value.filter(isRecord).map((condition) => ({ diff --git a/apps/desktop/src/renderer/hooks/useDiagnostics.ts b/apps/desktop/src/renderer/hooks/useDiagnostics.ts index 29a6a6c..21294e7 100644 --- a/apps/desktop/src/renderer/hooks/useDiagnostics.ts +++ b/apps/desktop/src/renderer/hooks/useDiagnostics.ts @@ -6,6 +6,8 @@ import type { ResourceRow, } from "../../shared/types"; import { desktop } from "../lib/desktop"; +import { SLOW_POLL_MS } from "../lib/poll-cadence"; +import { isRolloutWorkloadKind } from "../detail/workload-detail"; export interface DiagnosticsOptions { contextId: string; @@ -22,10 +24,17 @@ export interface DiagnosticsState { * Owns the diagnostic surface for the selected resource: recent events and * owner/child relationship queries. Pod logs live in the self-contained * LogViewer component, not here. + * + * Events re-fetch when the selection's resourceVersion bumps (the detail's + * single-object watch adopts those) and, for rollout workloads only (#39), + * on a slow interval as a backstop for child events that do not touch the + * object itself — so rollout events land without a manual refresh. Other + * kinds keep the one-shot fetch the selection change already drives. */ export function useDiagnostics({ contextId, kind, selected }: DiagnosticsOptions): DiagnosticsState { const [events, setEvents] = useState([]); const [related, setRelated] = useState([]); + const liveEvents = isRolloutWorkloadKind(kind.kind); useEffect(() => { if (!selected || !contextId || !selected.namespace) { @@ -33,11 +42,18 @@ export function useDiagnostics({ contextId, kind, selected }: DiagnosticsOptions return; } let active = true; - void desktop.resources.events({ contextId, resourceKind: kind, namespace: selected.namespace, name: selected.name }) - .then((items) => active && setEvents(items)) - .catch(() => active && setEvents([])); - return () => { active = false; }; - }, [contextId, kind, selected]); + const load = () => { + void desktop.resources.events({ contextId, resourceKind: kind, namespace: selected.namespace, name: selected.name }) + .then((items) => active && setEvents(items)) + .catch(() => active && setEvents([])); + }; + load(); + const timer = liveEvents ? setInterval(load, SLOW_POLL_MS) : undefined; + return () => { + active = false; + if (timer) clearInterval(timer); + }; + }, [contextId, kind, selected, liveEvents]); useEffect(() => { if (!selected || !contextId) { diff --git a/apps/desktop/src/renderer/hooks/useHelm.ts b/apps/desktop/src/renderer/hooks/useHelm.ts index 67b75c5..b926aa0 100644 --- a/apps/desktop/src/renderer/hooks/useHelm.ts +++ b/apps/desktop/src/renderer/hooks/useHelm.ts @@ -1,11 +1,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { HelmReleaseDetail, HelmReleaseSummary, HelmUpgradeRequest } from "../../shared/types"; +import type { HelmReleaseDetail, HelmReleaseSummary, HelmUpgradeRequest, NamespaceScope } from "../../shared/types"; import { toast } from "@/components/ui/toast"; import { desktop } from "../lib/desktop"; +import { namespaceScopeKey } from "../lib/namespace-scope"; export interface UseHelmOptions { contextId: string; - namespace: string; + /** + * Ordered namespace selection: [] lists across all namespaces (the wire's + * empty namespace), one name is the ordinary single-namespace stream, and + * two or more fan out as one single-namespace stream per namespace merged + * in selection order. + */ + namespaceScope: NamespaceScope; coreReady: boolean; } @@ -41,7 +48,16 @@ export interface HelmState { * operations; the core runs them synchronously, so the view blocks on one * action at a time and refreshes the list when it finishes. */ -export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): HelmState { +export function useHelm({ contextId, namespaceScope, coreReady }: UseHelmOptions): HelmState { + const scopeKey = namespaceScopeKey(namespaceScope); + // Row-scoped surfaces (select/upgrade/rollback) always act on a release that + // carries its own namespace; the primary selection is only the fallback. + const primaryNamespace = namespaceScope[0] ?? ""; + // Read through the ref inside the list effect: it keys on the canonical + // scope key, so an upstream array re-creation with equal contents cannot + // tear the streams down. + const scopeRef = useRef(namespaceScope); + scopeRef.current = namespaceScope; const [releases, setReleases] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -57,12 +73,12 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He const [progress, setProgress] = useState(""); // Same scope-reset contract as useResourceDetail: when the list scope - // (context or namespace) changes, close any open release detail so a stale - // cross-namespace read can't linger under a picker that says otherwise. + // (context or namespace scope) changes, close any open release detail so a + // stale cross-namespace read can't linger under a picker that says otherwise. useEffect(() => { setSelected(undefined); setDetailError(""); - }, [contextId, namespace]); + }, [contextId, scopeKey]); useEffect(() => { setReleases([]); @@ -72,57 +88,97 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He setLoadingMore(false); setLoading(Boolean(contextId && coreReady)); if (!contextId || !coreReady) return; + // "" keeps its wire meaning of all namespaces, so the scope maps to one + // ordinary list request per entry: a single entry (one namespace or All) + // is just the one-entry case of the same per-namespace stream fan-out — + // one code path, no special cases. + const namespaces = scopeRef.current.length ? scopeRef.current : [""]; let active = true; - let pending = false; - let pageRequest = 0; - let cursor = ""; - let retainedCursor = ""; - let stop = () => {}; - const close = (continueToken: string) => { + let loadingPages = 0; + let loadingMorePages = 0; + const pageRequests: Record = {}; + const stops: Array<() => void> = []; + const retainedCursors: Record = {}; + let pages: Record = {}; + const commit = () => { + const merged: HelmReleaseSummary[] = []; + let more = false; + for (const namespace of namespaces) { + const page = pages[namespace]; + if (!page) continue; + merged.push(...page.releases); + if (page.cursor) more = true; + } + setReleases(merged); + setHasMore(more); + }; + const close = (namespace: string, continueToken: string) => { void desktop.helm.closeList({ contextId, namespace, continueToken }).catch(() => {}); }; - const startPage = (continueToken: string) => { - if (!active || pending) return; - pending = true; - const current = ++pageRequest; - setLoading(!continueToken); - setLoadingMore(Boolean(continueToken)); + const startPage = (namespace: string, continueToken: string) => { + if (!active) return; + pageRequests[namespace] = (pageRequests[namespace] ?? 0) + 1; + const current = pageRequests[namespace]; + if (continueToken) loadingMorePages += 1; else loadingPages += 1; + if (!continueToken) setLoading(true); else setLoadingMore(true); setError(""); setProgress(""); - stop = desktop.helm.list({ contextId, namespace, continueToken: continueToken || undefined }, (event) => { - if (!active || current !== pageRequest) { - if (!active && event.kind === "done" && event.continueToken) close(event.continueToken); + const stop = desktop.helm.list({ contextId, namespace, continueToken: continueToken || undefined }, (event) => { + if (!active || current !== pageRequests[namespace]) { + if (!active && event.kind === "done" && event.continueToken) close(namespace, event.continueToken); return; } if (event.kind === "progress") return; - pending = false; - setLoading(false); - setLoadingMore(false); - if (event.kind === "error") { setError(event.message); return; } - // Commit the whole page once; heartbeat/progress never changes rows. - setReleases((items) => continueToken ? [...items, ...(event.releases ?? [])] : (event.releases ?? [])); - cursor = event.continueToken ?? ""; - if (cursor) retainedCursor = cursor; - setHasMore(Boolean(cursor)); + if (continueToken) loadingMorePages -= 1; else loadingPages -= 1; + setLoading(loadingPages > 0); + setLoadingMore(loadingMorePages > 0); + if (event.kind === "error") { + // Keep the loaded rows and the cursor: the namespace contributes + // nothing new while the others still render, and the footer's + // load-more retries the same page. + setError(event.message); + return; + } + // The first page replaces; a load-more page appends after the ones + // already loaded, exactly like the table's appendNamespacePage. + pages[namespace] = { + releases: continueToken + ? [...(pages[namespace]?.releases ?? []), ...(event.releases ?? [])] + : (event.releases ?? []), + cursor: event.continueToken ?? "", + }; + // Retain the last non-empty token per namespace, even after a page + // completes: the leaving view (scope change or refresh) must still + // release the server-side session that token pinned — matching the + // single-namespace stream's close-on-leave contract. + if (event.continueToken) retainedCursors[namespace] = event.continueToken; + commit(); }); + stops.push(stop); + }; + loadNextPage.current = () => { + for (const namespace of namespaces) { + const cursor = pages[namespace]?.cursor; + if (cursor) startPage(namespace, cursor); + } }; - loadNextPage.current = () => { if (cursor) startPage(cursor); }; stopList.current = () => { - ++pageRequest; - pending = false; - stop(); + for (const namespace of namespaces) pageRequests[namespace] = (pageRequests[namespace] ?? 0) + 1; + stops.forEach((stop) => stop()); + loadingPages = 0; + loadingMorePages = 0; setLoading(false); setLoadingMore(false); setProgress("Loading cancelled"); }; - startPage(""); + for (const namespace of namespaces) startPage(namespace, ""); return () => { active = false; - ++pageRequest; - stop(); - if (retainedCursor) close(retainedCursor); + for (const namespace of namespaces) pageRequests[namespace] = (pageRequests[namespace] ?? 0) + 1; + stops.forEach((stop) => stop()); + for (const [namespace, cursor] of Object.entries(retainedCursors)) close(namespace, cursor); }; - }, [contextId, namespace, coreReady, generation]); + }, [contextId, scopeKey, coreReady, generation]); const loadMore = useCallback(() => loadNextPage.current(), []); const cancel = useCallback(() => stopList.current(), []); @@ -130,7 +186,7 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He const refresh = useCallback(() => setGeneration((value) => value + 1), []); const select = useCallback(async (name: string, releaseNamespace?: string) => { - const ns = releaseNamespace || namespace; + const ns = releaseNamespace || primaryNamespace; if (!contextId || !ns || !name) return; setDetailLoading(true); setDetailError(""); @@ -142,7 +198,7 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He } finally { setDetailLoading(false); } - }, [contextId, namespace]); + }, [contextId, primaryNamespace]); const clear = useCallback(() => { setSelected(undefined); @@ -150,7 +206,7 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He }, []); const uninstall = useCallback(async (name: string) => { - const ns = selected?.namespace || namespace; + const ns = selected?.namespace || primaryNamespace; if (!contextId || !ns || busy) return; setBusy(true); setDetailError(""); @@ -164,10 +220,10 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He } finally { setBusy(false); } - }, [contextId, namespace, selected, busy, refresh]); + }, [contextId, primaryNamespace, selected, busy, refresh]); const rollback = useCallback(async (name: string, revision?: number) => { - const ns = selected?.namespace || namespace; + const ns = selected?.namespace || primaryNamespace; if (!contextId || !ns || busy) return; setBusy(true); setDetailError(""); @@ -180,10 +236,10 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He } finally { setBusy(false); } - }, [contextId, namespace, selected, busy, refresh]); + }, [contextId, primaryNamespace, selected, busy, refresh]); const upgrade = useCallback(async (input: HelmUpgradeInput): Promise => { - const ns = selected?.namespace || namespace; + const ns = selected?.namespace || primaryNamespace; if (!contextId || !ns || busy) return "Another operation is already in progress"; setBusy(true); setDetailError(""); @@ -202,7 +258,7 @@ export function useHelm({ contextId, namespace, coreReady }: UseHelmOptions): He } finally { setBusy(false); } - }, [contextId, namespace, selected, busy, select, refresh]); + }, [contextId, primaryNamespace, selected, busy, select, refresh]); return { releases, diff --git a/apps/desktop/src/renderer/hooks/useNamespaces.ts b/apps/desktop/src/renderer/hooks/useNamespaces.ts index bdb0071..8e30854 100644 --- a/apps/desktop/src/renderer/hooks/useNamespaces.ts +++ b/apps/desktop/src/renderer/hooks/useNamespaces.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { ContextInfo, NamespaceInfo } from "../../shared/types"; +import type { ContextInfo, NamespaceInfo, NamespaceScope } from "../../shared/types"; import { messageOf } from "../lib/format"; import { desktop } from "../lib/desktop"; import { @@ -16,8 +16,9 @@ export interface NamespacesState { loading: boolean; /** True after the lazy inventory has completed successfully, including empty results. */ loaded: boolean; - namespace: string; - setNamespace(namespace: string): void; + /** Ordered namespace selection; [] is the All-namespaces scope. */ + namespaceScope: NamespaceScope; + setNamespaceScope(scope: NamespaceScope): void; /** Lazily fetches the namespace list on first use (pickers, ⌘K). */ load(): void; } @@ -44,7 +45,7 @@ export function useNamespaces( const [namespaces, setNamespaces] = useState([]); const [truncated, setTruncated] = useState(false); const [loading, setLoading] = useState(false); - const [namespace, setNamespace] = useState(""); + const [namespaceScope, setNamespaceScope] = useState([]); const [loaded, setLoaded] = useState(false); const loadingRef = useRef(false); // Bumped on every context change so a slow fetch started under the previous @@ -87,12 +88,17 @@ export function useNamespaces( setLoading(false); setLoaded(false); if (!contextId) { - setNamespace(""); + setNamespaceScope((current) => (current.length ? [] : current)); return; } - // The context's default namespace is known without the list. + // The context's default namespace is known without the list. connectContext + // may already have applied the same scope: bail out on equal contents so + // the array identity — and with it every scope-keyed effect — stays put. const context = contexts.find((item) => item.id === contextId); - setNamespace(context?.namespace || ""); + const defaultNamespace = context?.namespace || ""; + setNamespaceScope((current) => ( + current.length === (defaultNamespace ? 1 : 0) && current[0] === defaultNamespace ? current : defaultNamespace ? [defaultNamespace] : [] + )); // A retained list renders at once; a stale one refreshes in the background. const cached = readNamespaceCache(contextId); if (cached) { @@ -118,5 +124,5 @@ export function useNamespaces( fetchList(true); }, [contextId, fetchList]); - return { namespaces, truncated, loading, loaded, namespace, setNamespace, load }; + return { namespaces, truncated, loading, loaded, namespaceScope, setNamespaceScope, load }; } diff --git a/apps/desktop/src/renderer/hooks/usePodEvents.ts b/apps/desktop/src/renderer/hooks/usePodEvents.ts new file mode 100644 index 0000000..3601e57 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/usePodEvents.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef, useState } from "react"; +import type { ResourceEvent, ResourceKind, ResourceRow } from "../../shared/types"; +import { desktop } from "../lib/desktop"; +import { SLOW_POLL_MS } from "../lib/poll-cadence"; +import { newestEventsFirst } from "../lib/resource-events"; + +/** Matches the events one-shot's bound; the poll never loads more than this. */ +const EVENT_LIMIT = 100; + +const EVENTS_KIND: ResourceKind = { + id: "events", + group: "", + version: "v1", + resource: "events", + kind: "Event", + namespaced: true, + category: "cluster", +}; + +/** Event rows carry their projection fields alongside the base row shape. */ +type EventRow = ResourceRow & { reason?: string; message?: string; type?: string }; + +export interface PodEventsOptions { + contextId: string; + namespace: string; + /** The workload's current pods; events are joined client-side on these names. */ + names: string[]; + enabled: boolean; +} + +/** + * Surfaces the rollout's child pod events (Pulling / Created / Started / + * Killing) for an open workload detail. One ordinary namespaced list request + * per poll — the events kind filtered server-side to pod events, joined + * client-side to the workload's own pod names — never a watch stream and + * never a per-pod fan-out. A poll finds nothing without a Metrics-Server-like + * error path: events are best-effort and an empty result renders as none. + */ +export function usePodEvents({ contextId, namespace, names, enabled }: PodEventsOptions): ResourceEvent[] { + const [events, setEvents] = useState([]); + // The name set rides a ref: each poll joins against whatever the pods list + // currently holds, so a live pod list refreshing cannot restart the poll. + // Only the empty↔non-empty transition is a dependency (nothing to join + // against means nothing to poll for). + const namesRef = useRef(names); + namesRef.current = names; + const hasNames = names.length > 0; + + useEffect(() => { + if (!enabled || !contextId || !namespace || !hasNames) { + setEvents([]); + return; + } + let active = true; + const load = () => { + void desktop.resources.list({ + contextId, + resourceKind: EVENTS_KIND, + namespace, + fieldSelector: "involvedObject.kind=Pod", + limit: EVENT_LIMIT, + }).then((response) => { + if (!active) return; + const current = new Set(namesRef.current); + const seen = new Set(); + const joined: ResourceEvent[] = []; + for (const row of response.items as EventRow[]) { + const pod = row.involvedObject || ""; + if (!pod || !current.has(pod)) continue; + if (seen.has(row.uid)) continue; + seen.add(row.uid); + joined.push({ + name: pod, + namespace: row.namespace, + reason: row.reason, + message: row.message, + type: row.type, + count: row.count, + lastTimestamp: row.lastTimestamp, + }); + } + setEvents(newestEventsFirst(joined, EVENT_LIMIT)); + }).catch(() => { + if (active) setEvents([]); + }); + }; + load(); + const timer = setInterval(load, SLOW_POLL_MS); + return () => { + active = false; + clearInterval(timer); + }; + }, [contextId, namespace, hasNames, enabled]); + + return events; +} diff --git a/apps/desktop/src/renderer/hooks/useResourceDetail.ts b/apps/desktop/src/renderer/hooks/useResourceDetail.ts index bd74f43..ed206f6 100644 --- a/apps/desktop/src/renderer/hooks/useResourceDetail.ts +++ b/apps/desktop/src/renderer/hooks/useResourceDetail.ts @@ -1,15 +1,25 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { ResourceGetResponse, ResourceKind, ResourceRow } from "../../shared/types"; +import type { ResourceGetResponse, ResourceKind, ResourceRow, ResourceWatchBatch } from "../../shared/types"; import { messageOf } from "../lib/format"; import { desktop } from "../lib/desktop"; export interface ResourceDetailOptions { contextId: string; kind: ResourceKind; - namespace: string; + /** Canonical key of the list's namespace scope; changes close the selection. */ + scopeKey: string; /** Changes whenever the list scope resets; closes any open selection. */ generation: number; items: ResourceRow[]; + /** False keeps the live channel idle: no watch, one-shot behavior. */ + coreReady: boolean; + /** + * True while the open object is a rollout workload, the only kind the live + * channel serves (#39): a single-object watch keyed on the object's + * identity. Other kinds keep the pre-existing behavior — the list watch + * bumps the selection — so no watch ever leaves with an empty namespace. + */ + live: boolean; } export interface ResourceDetailState { @@ -30,7 +40,11 @@ export interface ResourceDetailState { /** Identity tag of the object a fetched detail belongs to. */ function objectTag(row: ResourceRow): string { - return `${row.uid || `${row.kind}:${row.namespace}/${row.name}`}@${row.resourceVersion}`; + return `${objectIdentity(row)}@${row.resourceVersion}`; +} + +function objectIdentity(row: ResourceRow): string { + return row.uid || `${row.kind}:${row.namespace}/${row.name}`; } /** @@ -47,8 +61,16 @@ function isNewerResourceVersion(candidate: string, current: string): boolean { * Owns the selected row and its fetched detail. The selection follows list * updates (resourceVersion bumps) and closes when the row disappears or the * list scope resets. + * + * While a rollout workload is open it also owns that object's live channel: a + * single-object watch (fieldSelector metadata.name) — lazy, one stream, closed + * on leave — so the vitals, conditions and YAML converge during a rollout even + * when the underlying list is a snapshot-only scope (All namespaces) whose + * watch would otherwise never bump the selection. A delta bumping the object's + * resourceVersion re-gets the object once, which refreshes the YAML the same + * way an explicit refresh does. */ -export function useResourceDetail({ contextId, kind, namespace, generation, items }: ResourceDetailOptions): ResourceDetailState { +export function useResourceDetail({ contextId, kind, scopeKey, generation, items, coreReady, live }: ResourceDetailOptions): ResourceDetailState { const [selected, setSelected] = useState(); const [detail, setDetail] = useState(); const [detailError, setDetailError] = useState(""); @@ -66,7 +88,7 @@ export function useResourceDetail({ contextId, kind, namespace, generation, item setDetail(undefined); setDetailError(""); setRefreshing(false); - }, [contextId, kind, namespace, generation]); + }, [contextId, kind, scopeKey, generation]); useEffect(() => { if (!selected) return; @@ -114,6 +136,58 @@ export function useResourceDetail({ contextId, kind, namespace, generation, item void fetchDetail(selected); }, [contextId, kind, selected, fetchDetail]); + // The live channel keys on the object's identity, never on the selected row + // itself: a resourceVersion bump adopts the row without tearing the watch + // subscription down and restarting it. + const selectedIdentity = selected ? objectIdentity(selected) : ""; + useEffect(() => { + const target = selectedRef.current; + if (!target || !contextId || !coreReady || !live) return; + let active = true; + const adopt = (rows: ResourceRow[]) => { + const current = selectedRef.current; + if (!active || !current || objectIdentity(current) !== objectIdentity(target)) return; + const next = rows.find((row) => objectIdentity(row) === objectIdentity(target) + && isNewerResourceVersion(row.resourceVersion, current.resourceVersion)); + if (!next) return; + setSelected(next); + // Re-get so the YAML and conditions converge with the vitals; the + // request guard inside fetchDetail drops stale reads. + void fetchDetail(next); + }; + const stop = desktop.resources.watch({ + contextId, + resourceKind: kind, + namespace: target.namespace, + fieldSelector: `metadata.name=${target.name}`, + limit: 100, + }, (batch: ResourceWatchBatch) => { + if (batch.kind === "snapshot") { + adopt(batch.items); + return; + } + if (batch.kind === "delta") { + for (const event of batch.events) { + if (event.type === "deleted") { + // The open object vanished (deleted out from under the view): + // close the detail, exactly like the list-follow path does. + if (event.key === objectIdentity(target)) { + detailFor.current = ""; + setSelected(undefined); + setDetail(undefined); + } + continue; + } + adopt([event.row]); + } + } + }); + return () => { + active = false; + stop(); + }; + }, [contextId, kind, coreReady, live, selectedIdentity, fetchDetail]); + const select = useCallback((row: ResourceRow) => setSelected(row), []); const clear = useCallback(() => { detailFor.current = ""; diff --git a/apps/desktop/src/renderer/hooks/useResourceList.ts b/apps/desktop/src/renderer/hooks/useResourceList.ts index fd109db..6fbdbdd 100644 --- a/apps/desktop/src/renderer/hooks/useResourceList.ts +++ b/apps/desktop/src/renderer/hooks/useResourceList.ts @@ -1,14 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ResourceKind, ResourceListResponse, ResourceRow, ResourceWatchBatch } from "../../shared/types"; +import type { NamespaceScope, ResourceKind, ResourceListResponse, ResourceRow, ResourceWatchBatch } from "../../shared/types"; import { applyResourceWatchBatches } from "../lib/resource-watch"; import { readResourceListSnapshot, resourceListCacheKey, writeResourceListSnapshot, clearResourceListSnapshots } from "../lib/resource-list-cache"; +import { MULTI_SCOPE_CONTINUE, mergeNamespacePages, namespaceScopeKey } from "../lib/namespace-scope"; import { messageOf } from "../lib/format"; import { desktop } from "../lib/desktop"; export interface ResourceListOptions { contextId: string; kind: ResourceKind; - namespace: string; + /** + * Ordered namespace selection: [] is the cluster-wide All-namespaces scope, + * one name is the live-watch scope, two or more fan out as per-namespace + * snapshot requests merged in selection order. + */ + namespaceScope: NamespaceScope; coreReady: boolean; setError(message: string): void; /** Server-side selector pinning the list, e.g. a workload's pod selector. */ @@ -36,13 +42,16 @@ export interface ResourceListState { } /** - * A cluster-wide list (namespace unset on a namespaced kind) is never watched. - * Watching it would open one cluster-scoped watch stream whose per-object - * deltas flood the IPC channel and the API server in a 100k-namespace cluster, - * so the table gets the first snapshot page and manual refresh only. + * A cluster-wide list (All-namespaces scope on a namespaced kind) is never + * watched. Watching it would open one cluster-scoped watch stream whose + * per-object deltas flood the IPC channel and the API server in a 100k- + * namespace cluster, so the table gets the first snapshot page and manual + * refresh only. A multi-namespace selection is a bounded fan-out of ordinary + * single-namespace snapshot requests — also never watched, so the invariant + * "at most one watch per list view" survives. */ -function watchEnabled(kind: ResourceKind, namespace: string, enabled: boolean): boolean { - return enabled && (kind.namespaced ? namespace !== "" : true); +function liveWatchEnabled(kind: ResourceKind, scopeSize: number, enabled: boolean): boolean { + return enabled && (kind.namespaced ? scopeSize === 1 : true); } /** @@ -51,7 +60,7 @@ function watchEnabled(kind: ResourceKind, namespace: string, enabled: boolean): * the filter query, and the visible error. Selection resets triggered by * list scope changes live in useResourceDetail via `generation`. */ -export function useResourceList({ contextId, kind, namespace, coreReady, setError, labelSelector, enabled = true }: ResourceListOptions): ResourceListState { +export function useResourceList({ contextId, kind, namespaceScope, coreReady, setError, labelSelector, enabled = true }: ResourceListOptions): ResourceListState { const [list, setList] = useState({ items: [] }); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); @@ -73,14 +82,86 @@ export function useResourceList({ contextId, kind, namespace, coreReady, setErro } listRef.current = list; }, [contextId, list]); - const liveWatch = watchEnabled(kind, namespace, enabled); + + // Multi-scope fan-out state: one snapshot page per selected namespace, + // merged in selection order. The ref backs loadMore; the scope ref keeps + // effects keyed on the order-insensitive scope key from re-running (and + // re-fetching everything) on a mere reordering. + const [multiPages, setMultiPages] = useState>({}); + const multiPagesRef = useRef(multiPages); + const scopeRef = useRef(namespaceScope); + scopeRef.current = namespaceScope; + multiPagesRef.current = multiPages; + + const liveWatch = liveWatchEnabled(kind, namespaceScope.length, enabled); + // Cluster-scoped kinds ignore the scope entirely — their requests carry no + // namespace, so a stale multi selection must not fan out for them. + const multiScope = kind.namespaced && namespaceScope.length > 1; + const scopeKey = useMemo(() => namespaceScopeKey(namespaceScope), [namespaceScope]); + + // The multi-scope view derives from the per-namespace pages in selection + // order; the sentinel token only signals "some namespace has another page" + // and is never sent back to the core. + const merged = useMemo( + () => mergeNamespacePages(namespaceScope, multiScope ? multiPages : {}), + [namespaceScope, multiScope, multiPages], + ); + const listView: ResourceListResponse = multiScope + ? { items: merged.items, ...(merged.hasMore ? { continueToken: MULTI_SCOPE_CONTINUE } : {}) } + : list; + useEffect(() => { + listRef.current = listView; + }, [listView]); const loadMore = useCallback(async () => { - if (!contextId || !coreReady || !enabled || !list.continueToken) return; + if (!contextId || !coreReady || !enabled) return; + if (multiScope) { + const pages = multiPagesRef.current; + const pending = scopeRef.current.filter((namespace) => pages[namespace]?.continueToken); + if (!pending.length) return; + const request = ++listRequest.current; + setLoadingMore(true); + setError(""); + let next = pages; + await Promise.all(pending.map(async (namespace) => { + try { + const response = await desktop.resources.list({ + contextId, + resourceKind: kind, + namespace, + ...(labelSelector ? { labelSelector } : {}), + limit: 100, + continueToken: pages[namespace].continueToken, + }); + if (request !== listRequest.current) return; + next = { + ...next, + [namespace]: { + ...response, + items: [...(next[namespace]?.items ?? []), ...response.items], + }, + }; + } catch { + // Keep that namespace's loaded page and token; the footer button + // retries only the namespaces that still have one. + } + })); + if (request !== listRequest.current) { + setLoading(false); + setLoadingMore(false); + return; + } + multiPagesRef.current = next; + setMultiPages(next); + setLoadingMore(false); + return; + } + if (!list.continueToken) return; const request = ++listRequest.current; setLoadingMore(true); setError(""); try { + const namespace = scopeRef.current[0] ?? ""; const response = await desktop.resources.list({ contextId, resourceKind: kind, @@ -99,18 +180,23 @@ export function useResourceList({ contextId, kind, namespace, coreReady, setErro setLoadingMore(false); } } - }, [contextId, coreReady, enabled, kind, labelSelector, list.continueToken, namespace, setError]); + }, [contextId, coreReady, enabled, kind, labelSelector, list.continueToken, multiScope, setError]); useEffect(() => { if (!contextId || !coreReady || !enabled) return; + if (multiScope) return; // handled by the fan-out effect below ++listRequest.current; setError(""); watchQueue.current = []; watchHasSnapshot.current = false; + // Read through the ref: the effect keys on the canonical scope key, so a + // reordering (or an upstream array re-creation with equal contents) + // cannot tear the subscription down. + const namespace = scopeRef.current[0] ?? ""; // Only watched (namespace-scoped) views retain snapshots; cluster-wide // snapshot-only scopes keep their manual-refresh behavior untouched. - const cacheKey = liveWatch ? resourceListCacheKey(contextId, kind.id, namespace, labelSelector) : ""; + const cacheKey = liveWatch ? resourceListCacheKey(contextId, kind.id, scopeKey, labelSelector) : ""; const cached = cacheKey ? readResourceListSnapshot(cacheKey) : undefined; if (cached) { // Stale-while-revalidate: revisit renders the retained snapshot at once @@ -124,8 +210,8 @@ export function useResourceList({ contextId, kind, namespace, coreReady, setErro setList({ items: [] }); } - // Cluster-wide namespaced lists are snapshot-only (see watchEnabled): the - // initial page is fetched, watch never starts, and refresh re-fetches. + // Cluster-wide namespaced lists are snapshot-only (see liveWatchEnabled): + // the initial page is fetched, watch never starts, and refresh re-fetches. if (!liveWatch) { let active = true; desktop.resources.list({ @@ -190,23 +276,97 @@ export function useResourceList({ contextId, kind, namespace, coreReady, setErro writeResourceListSnapshot(cacheKey, listRef.current); } }; - }, [contextId, namespace, kind, coreReady, enabled, labelSelector, generation, setError, liveWatch]); + }, [contextId, kind, coreReady, enabled, labelSelector, generation, setError, liveWatch, multiScope, scopeKey]); + + // Multi-namespace fan-out: one ordinary single-namespace snapshot request + // per selected namespace (never a watch), merged in selection order. The + // request generation supersedes every in-flight page on scope change or + // refresh; a failed namespace degrades to "no rows from it" while the rest + // still render. + useEffect(() => { + if (!multiScope || !contextId || !coreReady || !enabled) return; + ++listRequest.current; + const request = listRequest.current; + setError(""); + const scope = scopeRef.current; + const cacheKey = resourceListCacheKey(contextId, kind.id, scopeKey, labelSelector); + const cached = readResourceListSnapshot(cacheKey); + let pages: Record = {}; + let completed = false; + if (cached) { + // Stale-while-revalidate: the retained merged view renders at once + // (without page tokens — they are not part of the merged snapshot) + // while every namespace re-fetches its fresh snapshot in place. + setList(cached); + setMultiPages({}); + setLoading(false); + setRevalidating(true); + } else { + setLoading(true); + setRevalidating(false); + setList({ items: [] }); + } + + const remaining = new Set(scope); + const settle = () => { + if (remaining.size > 0 || request !== listRequest.current) return; + completed = true; + setLoading(false); + setRevalidating(false); + }; + const fetchOne = async (namespace: string) => { + try { + const response = await desktop.resources.list({ + contextId, + resourceKind: kind, + namespace, + ...(labelSelector ? { labelSelector } : {}), + limit: 100, + }); + if (request !== listRequest.current) return; + pages = { ...pages, [namespace]: response }; + setMultiPages(pages); + remaining.delete(namespace); + settle(); + } catch (cause) { + if (request !== listRequest.current) return; + setError(messageOf(cause)); + remaining.delete(namespace); + settle(); + } + }; + for (const namespace of scope) void fetchOne(namespace); + + return () => { + if (request !== listRequest.current) return; + // Retain the leaving scope's merged rows for the next revisit. + if (completed && scope.length) { + const mergedSnapshot = mergeNamespacePages(scope, pages); + if (mergedSnapshot.items.length) { + writeResourceListSnapshot(cacheKey, { items: mergedSnapshot.items }); + } + } + }; + }, [contextId, multiScope, scopeKey, kind, coreReady, enabled, labelSelector, generation, setError]); const visibleRows = useMemo(() => { const needle = query.trim().toLowerCase(); - if (!needle) return list.items; - return list.items.filter((item) => + if (!needle) return listView.items; + return listView.items.filter((item) => item.name.toLowerCase().includes(needle) || item.namespace.toLowerCase().includes(needle) || item.status?.toLowerCase().includes(needle), ); - }, [list.items, query]); + }, [listView.items, query]); const refresh = useCallback(() => setGeneration((value) => value + 1), []); - const reset = useCallback(() => setList({ items: [] }), []); + const reset = useCallback(() => { + setList({ items: [] }); + setMultiPages({}); + }, []); return { - list, + list: listView, loading, loadingMore, revalidating, diff --git a/apps/desktop/src/renderer/lib/namespace-scope.test.ts b/apps/desktop/src/renderer/lib/namespace-scope.test.ts new file mode 100644 index 0000000..b8bd73b --- /dev/null +++ b/apps/desktop/src/renderer/lib/namespace-scope.test.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import type { ResourceListResponse, ResourceRow } from "../../shared/types"; +import { + MAX_NAMESPACE_SELECTION, + MULTI_SCOPE_CONTINUE, + appendNamespacePage, + mergeNamespacePages, + namespaceScopeKey, + namespaceScopeSummary, + toggleNamespace, +} from "./namespace-scope"; + +describe("toggleNamespace", () => { + it("appends a new namespace in click order", () => { + expect(toggleNamespace([], "a").scope).toEqual(["a"]); + expect(toggleNamespace(["a"], "b").scope).toEqual(["a", "b"]); + expect(toggleNamespace(["a"], "b").accepted).toBe(true); + }); + + it("removes a selected namespace and keeps the remaining order", () => { + const result = toggleNamespace(["a", "b", "c"], "b"); + expect(result.scope).toEqual(["a", "c"]); + expect(result.accepted).toBe(true); + }); + + it("removing still works at the cap", () => { + const full = Array.from({ length: MAX_NAMESPACE_SELECTION }, (_, i) => `ns-${i}`); + const result = toggleNamespace(full, "ns-5"); + expect(result.accepted).toBe(true); + expect(result.scope).toHaveLength(MAX_NAMESPACE_SELECTION - 1); + }); + + it("rejects the 33rd namespace without mutating the selection", () => { + const full = Array.from({ length: MAX_NAMESPACE_SELECTION }, (_, i) => `ns-${i}`); + const result = toggleNamespace(full, "one-too-many"); + expect(result.accepted).toBe(false); + expect(result.scope).toBe(full); + }); +}); + +describe("namespaceScopeKey", () => { + it("is order-insensitive", () => { + expect(namespaceScopeKey(["b", "a"])).toBe(namespaceScopeKey(["a", "b"])); + }); + + it("encodes the empty scope as the All-namespaces key", () => { + expect(namespaceScopeKey([])).toBe(""); + }); + + it("distinguishes scopes that are not permutations of each other", () => { + expect(namespaceScopeKey(["a"])).not.toBe(namespaceScopeKey(["a", "b"])); + expect(namespaceScopeKey(["a"])).toBe(namespaceScopeKey(["a"])); + }); +}); + +describe("namespaceScopeSummary", () => { + it("empty scope summarizes to nothing (the trigger says All namespaces)", () => { + expect(namespaceScopeSummary([])).toBe(""); + }); + + it("one name renders alone so single-selection reads exactly as before", () => { + expect(namespaceScopeSummary(["kube-system"])).toBe("kube-system"); + }); + + it("two names join with a comma in selection order", () => { + expect(namespaceScopeSummary(["a", "b"])).toBe("a, b"); + }); + + it("longer selections overflow into +N from three on", () => { + expect(namespaceScopeSummary(["a", "b", "c"])).toBe("a, b +1"); + expect(namespaceScopeSummary(["a", "b", "c", "d"])).toBe("a, b +2"); + expect(namespaceScopeSummary(["a", "b", "c", "d", "e", "f"])).toBe("a, b +4"); + }); +}); + +function page(items: string[], continueToken?: string): ResourceListResponse { + return { + items: items.map((name) => ({ + uid: `uid-${name}`, + apiVersion: "v1", + kind: "Pod", + name, + namespace: "ns", + resourceVersion: "1", + createdAt: "2026-08-01T00:00:00Z", + })), + ...(continueToken ? { continueToken } : {}), + }; +} + +const rowsOf = (response: ResourceListResponse) => response.items.map((row: ResourceRow) => row.name); + +describe("mergeNamespacePages", () => { + it("concatenates pages in selection order, not fetch order", () => { + const merged = mergeNamespacePages(["a", "b"], { b: page(["b-0"]), a: page(["a-0", "a-1"]) }); + expect(rowsOf({ items: merged.items } as ResourceListResponse)).toEqual(["a-0", "a-1", "b-0"]); + }); + + it("a missing page contributes nothing instead of stalling the merge", () => { + const merged = mergeNamespacePages(["a", "b", "c"], { a: page(["a-0"]), c: page(["c-0"]) }); + expect(rowsOf({ items: merged.items } as ResourceListResponse)).toEqual(["a-0", "c-0"]); + }); + + it("hasMore is true when any selected namespace still has a page", () => { + expect(mergeNamespacePages(["a", "b"], { a: page(["a-0"], "tok-a"), b: page(["b-0"]) }).hasMore).toBe(true); + expect(mergeNamespacePages(["a", "b"], { a: page(["a-0"]), b: page(["b-0"]) }).hasMore).toBe(false); + expect(mergeNamespacePages(["a", "b"], {}).hasMore).toBe(false); + }); + + it("empty scope merges to nothing", () => { + expect(mergeNamespacePages([], { a: page(["a-0"]) })).toEqual({ items: [], hasMore: false }); + }); +}); + +describe("appendNamespacePage", () => { + it("appends the next page after the loaded ones and adopts the new token", () => { + const pages = { a: page(["a-0"], "tok") }; + const next = appendNamespacePage(pages, "a", page(["a-1"], "tok-2")); + expect(rowsOf(next.a)).toEqual(["a-0", "a-1"]); + expect(next.a.continueToken).toBe("tok-2"); + // Pure: the input record is untouched. + expect(rowsOf(pages.a)).toEqual(["a-0"]); + }); + + it("a failed namespace keeps its old token so load-more retries it", () => { + const pages = { a: page(["a-0"], "tok-a"), b: page(["b-0"], "tok-b") }; + const next = appendNamespacePage(pages, "a", page(["a-1"])); + expect(next.a.continueToken).toBeUndefined(); + expect(next.b.continueToken).toBe("tok-b"); + }); + + it("the multi-scope sentinel is a truthy marker, never sent to the core", () => { + expect(MULTI_SCOPE_CONTINUE).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/namespace-scope.ts b/apps/desktop/src/renderer/lib/namespace-scope.ts new file mode 100644 index 0000000..a020aad --- /dev/null +++ b/apps/desktop/src/renderer/lib/namespace-scope.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ResourceListResponse, ResourceRow } from "../../shared/types"; + +/** + * The namespace scope of a list view: an ordered set of selected namespace + * names, where the empty array is the "All namespaces" cluster-wide scope. + * Order is the user's selection order and drives merged-row grouping; the + * canonical key (namespaceScopeKey) is order-insensitive and identifies the + * scope wherever an identity is needed (snapshot cache, reset effects). + * The wire format stays single-namespace: every fan-out request is an + * ordinary one-namespace call. + */ + +/** Bounds the per-namespace fan-out: worst case 32 list + 32 metrics requests per refresh. */ +export const MAX_NAMESPACE_SELECTION = 32; + +/** Sentinel continue token marking "some namespace in a multi scope has another page". */ +export const MULTI_SCOPE_CONTINUE = "\u0000multi-scope"; + +export interface NamespaceToggle { + scope: string[]; + /** False when adding would exceed MAX_NAMESPACE_SELECTION; scope is unchanged. */ + accepted: boolean; +} + +/** Toggles one namespace in the selection; removing is always allowed. */ +export function toggleNamespace(scope: string[], name: string): NamespaceToggle { + if (scope.includes(name)) { + return { scope: scope.filter((item) => item !== name), accepted: true }; + } + if (scope.length >= MAX_NAMESPACE_SELECTION) { + return { scope, accepted: false }; + } + return { scope: [...scope, name], accepted: true }; +} + +/** + * Order-insensitive identity of a scope: the sorted names joined with \u001f + * (a character namespace names cannot contain), "" meaning All namespaces. + * Used for snapshot-cache keys and reset effects, where [a, b] and [b, a] + * are the same scope. + */ +export function namespaceScopeKey(scope: string[]): string { + return [...scope].sort().join("\u001f"); +} + +/** + * Trigger summary: "" for All, the name alone for one selection, two names + * joined, and a "+N" overflow from three on so long names cannot push the + * toolbar's search and actions out of reach. + */ +export function namespaceScopeSummary(scope: string[]): string { + if (scope.length === 0) return ""; + if (scope.length <= 2) return scope.join(", "); + return `${scope.slice(0, 2).join(", ")} +${scope.length - 2}`; +} + +export interface MergedNamespacePages { + /** Rows concatenated in selection order, one namespace after another. */ + items: ResourceRow[]; + /** True when any namespace still has a server page to load. */ + hasMore: boolean; +} + +/** + * Merges one snapshot page per selected namespace into the ordered view. + * Missing pages (a namespace still loading or failed) contribute nothing + * rather than stalling the merge. + */ +export function mergeNamespacePages( + scope: string[], + pages: Readonly>, +): MergedNamespacePages { + const items: ResourceRow[] = []; + let hasMore = false; + for (const namespace of scope) { + const page = pages[namespace]; + if (!page) continue; + items.push(...page.items); + if (page.continueToken) hasMore = true; + } + return { items, hasMore }; +} + +/** Returns a new pages record with one namespace's next page appended. */ +export function appendNamespacePage( + pages: Readonly>, + namespace: string, + response: ResourceListResponse, +): Record { + const current = pages[namespace]; + return { + ...pages, + [namespace]: { + ...response, + items: [...(current?.items ?? []), ...response.items], + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/poll-cadence.ts b/apps/desktop/src/renderer/lib/poll-cadence.ts new file mode 100644 index 0000000..7ca0f17 --- /dev/null +++ b/apps/desktop/src/renderer/lib/poll-cadence.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared slow-poll cadence for best-effort surfaces (pod usage, events). + * One named clock keeps the app's background chatter in step: surfaces that + * fan out per namespace all fire on the same tick instead of drifting. + */ +export const SLOW_POLL_MS = 15_000; diff --git a/apps/desktop/src/renderer/lib/resource-events.ts b/apps/desktop/src/renderer/lib/resource-events.ts new file mode 100644 index 0000000..288235b --- /dev/null +++ b/apps/desktop/src/renderer/lib/resource-events.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ResourceEvent } from "../../shared/types"; + +/** + * The shared tail of every event timeline in the app: sort newest-first by + * lastTimestamp, cap the timeline, and return plain rows. Both halves of the + * Events tab (the object's one-shot fetch and the pod-events poll) shape + * their output through this. + */ +export function newestEventsFirst(events: ResourceEvent[], limit: number): ResourceEvent[] { + const withKeys = events.map((event) => ({ ...event, sortKey: event.lastTimestamp || "" })); + withKeys.sort((a, b) => (a.sortKey < b.sortKey ? 1 : a.sortKey > b.sortKey ? -1 : 0)); + return withKeys.slice(0, limit).map(({ sortKey: _sortKey, ...event }) => event); +} diff --git a/apps/desktop/src/renderer/lib/resource-favorites.test.ts b/apps/desktop/src/renderer/lib/resource-favorites.test.ts new file mode 100644 index 0000000..e67eafa --- /dev/null +++ b/apps/desktop/src/renderer/lib/resource-favorites.test.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { mostUsedKindIds, recordKindUsage, toggleFavoriteKind } from "./resource-favorites"; + +describe("toggleFavoriteKind", () => { + it("appends in starring order and removes without reordering the rest", () => { + let favorites = toggleFavoriteKind([], "pods"); + favorites = toggleFavoriteKind(favorites, "deployments"); + favorites = toggleFavoriteKind(favorites, "services"); + expect(favorites).toEqual(["pods", "deployments", "services"]); + expect(toggleFavoriteKind(favorites, "deployments")).toEqual(["pods", "services"]); + }); + + it("toggling twice returns to the original list", () => { + expect(toggleFavoriteKind(toggleFavoriteKind(["pods"], "jobs"), "jobs")).toEqual(["pods"]); + }); +}); + +describe("recordKindUsage", () => { + it("bumps the counter without mutating the input", () => { + const usage = { pods: 2 }; + expect(recordKindUsage(usage, "pods")).toEqual({ pods: 3 }); + expect(recordKindUsage(usage, "jobs")).toEqual({ pods: 2, jobs: 1 }); + expect(usage).toEqual({ pods: 2 }); + }); +}); + +describe("mostUsedKindIds", () => { + it("ranks by open count, best first, capped at the limit", () => { + const usage = { pods: 9, deployments: 5, services: 2, jobs: 1 }; + expect(mostUsedKindIds(usage, new Set(), 3)).toEqual(["pods", "deployments", "services"]); + }); + + it("skips starred kinds and zero counts", () => { + const usage = { pods: 9, deployments: 5, services: 0 }; + expect(mostUsedKindIds(usage, new Set(["pods"]), 3)).toEqual(["deployments"]); + }); + + it("breaks ties deterministically so restarts keep the order", () => { + const usage = { services: 2, jobs: 2 }; + expect(mostUsedKindIds(usage, new Set(), 3)).toEqual(["jobs", "services"]); + }); +}); diff --git a/apps/desktop/src/renderer/lib/resource-favorites.ts b/apps/desktop/src/renderer/lib/resource-favorites.ts new file mode 100644 index 0000000..1eba1c6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/resource-favorites.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Sidebar quick-access bookkeeping: the kinds the operator stars (Favorites) + * and the kinds they actually open (Most used). Both are renderer-side UI + * preferences persisted in localStorage like the sidebar fold prefs — no + * cluster data, no shell involvement. + */ + +/** Toggles one kind in the starred list; order is the starring order. */ +export function toggleFavoriteKind(favorites: string[], id: string): string[] { + return favorites.includes(id) + ? favorites.filter((item) => item !== id) + : [...favorites, id]; +} + +/** Bumps one kind's open counter. */ +export function recordKindUsage( + usage: Record, + id: string, +): Record { + return { ...usage, [id]: (usage[id] ?? 0) + 1 }; +} + +/** + * The most-opened kinds, best first, capped and with already-starred kinds + * filtered out (a Favorited kind needs no second entry pointing at it). + * Ties resolve to the lexicographically smaller id so the list is stable + * across restarts. + */ +export function mostUsedKindIds( + usage: Record, + exclude: ReadonlySet, + limit: number, +): string[] { + return Object.entries(usage) + .filter(([id, count]) => count > 0 && !exclude.has(id)) + .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)) + .slice(0, limit) + .map(([id]) => id); +} diff --git a/apps/desktop/src/renderer/shell/Sidebar.tsx b/apps/desktop/src/renderer/shell/Sidebar.tsx index 5bed491..a81a5e4 100644 --- a/apps/desktop/src/renderer/shell/Sidebar.tsx +++ b/apps/desktop/src/renderer/shell/Sidebar.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState, type ComponentType } from "react"; +import { useEffect, useMemo, useState, type ComponentType } from "react"; import { Boxes, ChevronDown, ChevronRight, Gauge, + Star, type LucideProps, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { pluralize } from "../lib/format"; +import { flattenResourceGroups } from "../lib/resource-catalog"; import type { ContextInfo, ResourceKind } from "../../shared/types"; export type SidebarIcon = ComponentType; @@ -54,6 +56,11 @@ export interface SidebarProps { toolGroups?: SidebarToolGroup[]; activeToolId?: string; onSelectTool?(id: string): void; + /** Kinds starred by the operator, in starring order; rendered at the top. */ + favoriteKindIds?: string[]; + onToggleFavoriteKind?(id: string): void; + /** Most-opened kinds (best first); hidden when empty. */ + mostUsedKindIds?: string[]; } const GROUP_COLLAPSE_STORAGE_KEY = "aster.sidebar.groupCollapsed"; @@ -87,8 +94,12 @@ export function Sidebar({ toolGroups, activeToolId, onSelectTool, + favoriteKindIds, + onToggleFavoriteKind, + mostUsedKindIds, }: SidebarProps) { const [groupPrefs, setGroupPrefs] = useState(readGroupCollapsePrefs); + const starred = useMemo(() => new Set(favoriteKindIds ?? []), [favoriteKindIds]); const setGroupCollapsed = (key: string, collapsedValue: boolean) => { setGroupPrefs((prefs) => { @@ -117,39 +128,68 @@ export function Sidebar({ return pref === undefined ? !defaultCollapsed(group, nested) : !pref; }; - const renderItem = (item: SidebarResourceItem) => { + const renderItem = (item: SidebarResourceItem, options?: { testIdPrefix?: string; star?: boolean }) => { // The kind highlight only applies while the resource table is the active // pane; the overview and tool panes highlight their own sidebar entries. const active = !overviewActive && !activeToolId && activeKind.id === item.id; const enabled = item.enabled !== false; const ItemIcon = item.icon; const label = item.label || pluralize(item.kind); + const testId = `${options?.testIdPrefix ?? "resource-nav"}-${safeTestId(item.id)}`; + const favorited = starred.has(item.id); + const body = ( + <> +