@skyhook-io/k8s-ui 1.8.11 → 1.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -1,19 +1,52 @@
1
- import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
- import { AlertTriangle, ArrowLeft, Boxes, ChevronDown, ExternalLink, Layers, Radio, Search } from 'lucide-react'
3
- import { clsx } from 'clsx'
4
- import type { ResourceRef, Topology, TopologyNode } from '../../types'
5
- import argoCdLogo from '../../assets/gitops/argocd.png'
6
- import fluxLogo from '../../assets/gitops/flux.svg'
7
- import { StatusDot, mapHealthToTone } from '../ui/status-tone'
8
- import { Tooltip } from '../ui/Tooltip'
9
- import { EmptyState } from '../ui/EmptyState'
10
- import { ResourceRefBadge } from '../ui/drawer-components'
11
- import { TopologyGraph } from '../topology/TopologyGraph'
12
- import { pluralize } from '../../utils/pluralize'
13
- import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
14
- import { batchRunParentNodes, tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
15
- import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
16
- import { getTopologyIcon } from '../../utils/resource-icons'
1
+ import {
2
+ useMemo,
3
+ useState,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ AlertTriangle,
11
+ Activity,
12
+ ArrowLeft,
13
+ Boxes,
14
+ ChevronDown,
15
+ Clock3,
16
+ DollarSign,
17
+ ExternalLink,
18
+ GitCommit,
19
+ Layers,
20
+ Radio,
21
+ RefreshCw,
22
+ Search,
23
+ } from "lucide-react";
24
+ import { clsx } from "clsx";
25
+ import type { ResourceRef, Topology, TopologyNode } from "../../types";
26
+ import argoCdLogo from "../../assets/gitops/argocd.png";
27
+ import fluxLogo from "../../assets/gitops/flux.svg";
28
+ import { StatusDot, mapHealthToTone } from "../ui/status-tone";
29
+ import { Tooltip } from "../ui/Tooltip";
30
+ import { EmptyState } from "../ui/EmptyState";
31
+ import { ResourceRefBadge } from "../ui/drawer-components";
32
+ import { TopologyGraph } from "../topology/TopologyGraph";
33
+ import { pluralize } from "../../utils/pluralize";
34
+ import { kindToPlural, refToSelectedResource } from "../../utils/navigation";
35
+ import {
36
+ batchRunParentNodes,
37
+ tagWorkloadOwnership,
38
+ seedNodeIds,
39
+ ownershipOf,
40
+ workloadKey,
41
+ type NeighborhoodSeed,
42
+ } from "../../utils/topology-neighborhood";
43
+ import {
44
+ workloadHue,
45
+ NEUTRAL_OWNER,
46
+ type WorkloadFocus,
47
+ } from "../../utils/workload-colors";
48
+ import { getTopologyIcon } from "../../utils/resource-icons";
49
+ import { getKindLabel } from "../../utils/api-resources";
17
50
  import {
18
51
  type AppRow,
19
52
  type AppHistory,
@@ -43,13 +76,31 @@ import {
43
76
  compareVersions,
44
77
  appSourceLabel,
45
78
  overlayProvenance,
46
- } from '../../utils/applications'
47
- import { PaneLoader } from '../ui/PaneLoader'
48
- import { midTruncate } from '../../utils/format'
49
- import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
50
- import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
51
- import { ReadyBar } from './ReadyBar'
52
- import { collapseStableReplicaSets, layerDeploymentInventory, topologyGroup, type DeploymentInventory, type DeploymentTopologyLayer } from '../../utils/application-topology'
79
+ } from "../../utils/applications";
80
+ import { PaneLoader } from "../ui/PaneLoader";
81
+ import { midTruncate } from "../../utils/format";
82
+ import { VersionTooltip, AppIdentityTooltip } from "./AppTooltips";
83
+ import {
84
+ ProvenanceBadge,
85
+ ClassBadge,
86
+ CategoryChip,
87
+ VersionInfo,
88
+ BatchSignalChip,
89
+ } from "./AppChips";
90
+ import { ReadyBar } from "./ReadyBar";
91
+ import {
92
+ collapseStableReplicaSets,
93
+ layerDeploymentInventory,
94
+ topologyGroup,
95
+ type DeploymentInventory,
96
+ type DeploymentTopologyLayer,
97
+ } from "../../utils/application-topology";
98
+ import {
99
+ buildApplicationHistoryItems,
100
+ type ApplicationHistoryCategory,
101
+ type ApplicationHistoryItem,
102
+ type ApplicationHistoryRange,
103
+ } from "../../utils/application-history";
53
104
 
54
105
  // ApplicationDetail owns the application chrome and scope switcher. The selected
55
106
  // scope decides the one tab row shown in the detail pane: app scope gets
@@ -77,10 +128,17 @@ export interface AppIdentityInstance {
77
128
  type SelectionProps =
78
129
  | {
79
130
  selectedWorkloadKey: string | null;
80
- onSelectWorkload: (key: string | null) => void;
131
+ onSelectWorkload: (
132
+ key: string | null,
133
+ options?: AppWorkloadSelectionOptions,
134
+ ) => void;
81
135
  }
82
136
  | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined };
83
137
 
138
+ export interface AppWorkloadSelectionOptions {
139
+ tab?: string;
140
+ }
141
+
84
142
  type CanonicalApplicationView = "overview" | "topology" | "history";
85
143
 
86
144
  export type ApplicationView = CanonicalApplicationView;
@@ -114,6 +172,17 @@ export type ApplicationDetailProps = {
114
172
  onBack: () => void;
115
173
  /** Render the host's WorkloadView for the chosen workload. */
116
174
  renderWorkload: (workload: SelectedAppWorkload) => ReactNode;
175
+ /** Optional host-rendered app-scope Cost view. When absent, no Cost tab is shown. */
176
+ renderCostView?: (props: {
177
+ app: AppRow;
178
+ workloads: AppWorkload[];
179
+ onSelectWorkload: (
180
+ workload: AppWorkload,
181
+ options?: AppWorkloadSelectionOptions,
182
+ ) => void;
183
+ }) => ReactNode;
184
+ costViewSelected?: boolean;
185
+ onSelectCostView?: () => void;
117
186
  /** Render host-scoped operational issues for the app overview. */
118
187
  renderOverviewIssues?: () => ReactNode;
119
188
  /** Whether the host-scoped issue surface contains current issues. */
@@ -123,9 +192,9 @@ export type ApplicationDetailProps = {
123
192
  topology?: Topology;
124
193
  /** True while the host's topology fetch is in flight. Without it, a
125
194
  * multi-workload app can briefly show an empty topology while topology loads. */
126
- topologyLoading?: boolean
195
+ topologyLoading?: boolean;
127
196
  /** Exact resources reported by the app's Argo CD, Flux, or Helm deployment source. */
128
- deploymentInventory?: DeploymentInventory
197
+ deploymentInventory?: DeploymentInventory;
129
198
  /** Open a related (non-workload) resource clicked in the app graph. */
130
199
  onNavigateToResource?: (resource: {
131
200
  kind: string;
@@ -158,6 +227,19 @@ export type ApplicationDetailProps = {
158
227
  activeInstanceKey?: string;
159
228
  history?: AppHistory;
160
229
  historyLoading?: boolean;
230
+ historyItems?: ApplicationHistoryItem[];
231
+ historyRuntimeLoading?: boolean;
232
+ historyRuntimeError?: boolean;
233
+ historyMode?: "local" | "retained";
234
+ historyRange?: ApplicationHistoryRange;
235
+ historyRangeOptions?: Array<{
236
+ value: ApplicationHistoryRange;
237
+ label: string;
238
+ }>;
239
+ historyCoverageRecordCount?: number;
240
+ historyRuntimeLimited?: boolean;
241
+ onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
242
+ onOpenTimeline?: (timestamp?: string) => void;
161
243
  onOpenSource?: (source: AppSourceRef) => void;
162
244
  } & SelectionProps &
163
245
  ViewProps;
@@ -220,7 +302,42 @@ function compareDefinedVersions(
220
302
  return compareVersions(a, b) ?? 0;
221
303
  }
222
304
 
223
- export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewIssues, hasOverviewIssues, topology, topologyLoading, deploymentInventory, onNavigateToResource, onSelectWorkloadRun, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, history, historyLoading, onOpenSource, selectedWorkloadKey, onSelectWorkload, selectedView, onSelectView }: ApplicationDetailProps) {
305
+ export function ApplicationDetail({
306
+ app,
307
+ onBack,
308
+ renderWorkload,
309
+ renderCostView,
310
+ costViewSelected,
311
+ onSelectCostView,
312
+ renderOverviewIssues,
313
+ hasOverviewIssues,
314
+ topology,
315
+ topologyLoading,
316
+ deploymentInventory,
317
+ onNavigateToResource,
318
+ onSelectWorkloadRun,
319
+ identityInstances,
320
+ onSwitchInstance,
321
+ discoveredEnvs,
322
+ activeInstanceKey,
323
+ history,
324
+ historyLoading,
325
+ historyItems,
326
+ historyRuntimeLoading,
327
+ historyRuntimeError,
328
+ historyMode,
329
+ historyRange,
330
+ historyRangeOptions,
331
+ historyCoverageRecordCount,
332
+ historyRuntimeLimited,
333
+ onHistoryRangeChange,
334
+ onOpenTimeline,
335
+ onOpenSource,
336
+ selectedWorkloadKey,
337
+ onSelectWorkload,
338
+ selectedView,
339
+ onSelectView,
340
+ }: ApplicationDetailProps) {
224
341
  // Stable order regardless of API ordering: rail rows and the per-workload
225
342
  // color assignment both follow this array, so an order flap between
226
343
  // refetches must not reshuffle rows or reassign a workload's hue.
@@ -262,14 +379,27 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
262
379
  const [internalView, setInternalView] = useState<CanonicalApplicationView>(
263
380
  DEFAULT_APPLICATION_VIEW,
264
381
  );
382
+ const [internalCostSelected, setInternalCostSelected] = useState(false);
265
383
  const activeView = canonicalApplicationView(selectedView ?? internalView);
384
+ const costSelected = Boolean(
385
+ renderCostView && (costViewSelected ?? internalCostSelected),
386
+ );
266
387
  const setView = useCallback(
267
- (view: CanonicalApplicationView) =>
268
- onSelectView ? onSelectView(view) : setInternalView(view),
388
+ (view: CanonicalApplicationView) => {
389
+ setInternalCostSelected(false);
390
+ if (onSelectView) onSelectView(view);
391
+ else setInternalView(view);
392
+ },
269
393
  [onSelectView],
270
394
  );
395
+ const selectCostView = useCallback(() => {
396
+ if (!renderCostView) return;
397
+ if (onSelectCostView) onSelectCostView();
398
+ else setInternalCostSelected(true);
399
+ }, [onSelectCostView, renderCostView]);
271
400
  useEffect(() => {
272
401
  if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW);
402
+ setInternalCostSelected(false);
273
403
  }, [app.key, selectedView]);
274
404
 
275
405
  const [internalSelected, setInternalSelected] = useState<string | null>(null);
@@ -280,8 +410,10 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
280
410
  ? selectedWorkloadKey
281
411
  : (internalSelected ?? implicitSingleWorkloadKey);
282
412
  const setSelected = useCallback(
283
- (key: string | null) =>
284
- onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key),
413
+ (key: string | null, options?: AppWorkloadSelectionOptions) =>
414
+ onSelectWorkload
415
+ ? onSelectWorkload(key, options)
416
+ : setInternalSelected(key),
285
417
  [onSelectWorkload],
286
418
  );
287
419
  const selectedWorkload = rawSelected
@@ -304,17 +436,19 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
304
436
  // Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
305
437
  // while the rest of the graph dims. Driven by the rail and, reciprocally, by
306
438
  // hovering a node.
307
- const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
308
- const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<Set<string>>(new Set())
309
- useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key])
439
+ const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null);
440
+ const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<
441
+ Set<string>
442
+ >(new Set());
443
+ useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key]);
310
444
  const toggleReplicaSets = useCallback((ownerID: string) => {
311
445
  setExpandedReplicaSetOwners((current) => {
312
- const next = new Set(current)
313
- if (next.has(ownerID)) next.delete(ownerID)
314
- else next.add(ownerID)
315
- return next
316
- })
317
- }, [])
446
+ const next = new Set(current);
447
+ if (next.has(ownerID)) next.delete(ownerID);
448
+ else next.add(ownerID);
449
+ return next;
450
+ });
451
+ }, []);
318
452
 
319
453
  const appSeeds = useMemo(
320
454
  () =>
@@ -328,20 +462,31 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
328
462
  );
329
463
  // Neighborhood subgraph + per-workload color/ownership tagging in one pass.
330
464
  const composedTopology = useMemo(
331
- () => topology ? collapseStableReplicaSets(topology, expandedReplicaSetOwners) : null,
465
+ () =>
466
+ topology
467
+ ? collapseStableReplicaSets(topology, expandedReplicaSetOwners)
468
+ : null,
332
469
  [topology, expandedReplicaSetOwners],
333
- )
470
+ );
334
471
  const ownership = useMemo(
335
- () => (composedTopology ? tagWorkloadOwnership(composedTopology, appSeeds) : null),
472
+ () =>
473
+ composedTopology
474
+ ? tagWorkloadOwnership(composedTopology, appSeeds)
475
+ : null,
336
476
  [composedTopology, appSeeds],
337
- )
338
- const appGraph = ownership?.topology ?? null
477
+ );
478
+ const appGraph = ownership?.topology ?? null;
339
479
  const deploymentLayer = useMemo(
340
- () => appGraph && deploymentInventory && app.sourceRef
341
- ? layerDeploymentInventory(appGraph, deploymentInventory, sourceInventoryLabel(app.sourceRef))
342
- : null,
480
+ () =>
481
+ appGraph && deploymentInventory && app.sourceRef
482
+ ? layerDeploymentInventory(
483
+ appGraph,
484
+ deploymentInventory,
485
+ sourceInventoryLabel(app.sourceRef),
486
+ )
487
+ : null,
343
488
  [appGraph, deploymentInventory, app.sourceRef],
344
- )
489
+ );
345
490
  const appGraphFocusId = useMemo(
346
491
  () => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
347
492
  [topology, appSeeds],
@@ -370,11 +515,14 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
370
515
  const handleAppNodeClick = useCallback(
371
516
  (node: TopologyNode) => {
372
517
  if (node.data?.sourceInventoryGroup && app.sourceRef) {
373
- onOpenSource?.(app.sourceRef)
374
- return
518
+ onOpenSource?.(app.sourceRef);
519
+ return;
375
520
  }
376
- const ns = (node.data?.namespace as string) || ''
377
- const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
521
+ const ns = (node.data?.namespace as string) || "";
522
+ const match = workloads.find(
523
+ (w) =>
524
+ w.kind === node.kind && w.name === node.name && w.namespace === ns,
525
+ );
378
526
  if (match) {
379
527
  setSelected(workloadKey(match));
380
528
  return;
@@ -399,14 +547,22 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
399
547
  namespace: ns,
400
548
  name: node.name,
401
549
  group: topologyGroup(node),
402
- })
550
+ });
403
551
  },
404
- [app.sourceRef, appGraph, workloads, onNavigateToResource, onOpenSource, onSelectWorkloadRun, setSelected],
405
- )
552
+ [
553
+ app.sourceRef,
554
+ appGraph,
555
+ workloads,
556
+ onNavigateToResource,
557
+ onOpenSource,
558
+ onSelectWorkloadRun,
559
+ setSelected,
560
+ ],
561
+ );
406
562
 
407
- const appTopology = deploymentLayer?.topology ?? appGraph ?? null
408
- const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0
409
- const colorByWorkload = ownership?.colorByWorkload ?? null
563
+ const appTopology = deploymentLayer?.topology ?? appGraph ?? null;
564
+ const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0;
565
+ const colorByWorkload = ownership?.colorByWorkload ?? null;
410
566
 
411
567
  return (
412
568
  <div className="flex min-h-0 w-full flex-1 flex-col bg-theme-base">
@@ -561,7 +717,10 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
561
717
  <ApplicationWorkspace
562
718
  app={app}
563
719
  activeView={activeView}
720
+ costSelected={costSelected}
564
721
  onViewChange={setView}
722
+ onCostViewChange={selectCostView}
723
+ renderCostView={renderCostView}
565
724
  workloads={workloads}
566
725
  ready={ready}
567
726
  desired={desired}
@@ -577,10 +736,22 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
577
736
  onNodeClick={handleAppNodeClick}
578
737
  onNodeHover={handleNodeHover}
579
738
  onFocusWorkload={setFocusedOwnerId}
580
- onSelectWorkload={(workload) => setSelected(workloadKey(workload))}
739
+ onSelectWorkload={(workload, options) =>
740
+ setSelected(workloadKey(workload), options)
741
+ }
581
742
  onNavigateToResource={onNavigateToResource}
582
743
  history={history}
583
744
  historyLoading={historyLoading}
745
+ historyItems={historyItems}
746
+ historyRuntimeLoading={historyRuntimeLoading}
747
+ historyRuntimeError={historyRuntimeError}
748
+ historyMode={historyMode}
749
+ historyRange={historyRange}
750
+ historyRangeOptions={historyRangeOptions}
751
+ historyCoverageRecordCount={historyCoverageRecordCount}
752
+ historyRuntimeLimited={historyRuntimeLimited}
753
+ onHistoryRangeChange={onHistoryRangeChange}
754
+ onOpenTimeline={onOpenTimeline}
584
755
  onOpenSource={onOpenSource}
585
756
  onToggleReplicaSets={toggleReplicaSets}
586
757
  renderOverviewIssues={renderOverviewIssues}
@@ -595,7 +766,10 @@ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewI
595
766
  function ApplicationWorkspace({
596
767
  app,
597
768
  activeView,
769
+ costSelected,
598
770
  onViewChange,
771
+ onCostViewChange,
772
+ renderCostView,
599
773
  workloads,
600
774
  ready,
601
775
  desired,
@@ -615,49 +789,87 @@ function ApplicationWorkspace({
615
789
  onNavigateToResource,
616
790
  history,
617
791
  historyLoading,
792
+ historyItems,
793
+ historyRuntimeLoading,
794
+ historyRuntimeError,
795
+ historyMode,
796
+ historyRange,
797
+ historyRangeOptions,
798
+ historyCoverageRecordCount,
799
+ historyRuntimeLimited,
800
+ onHistoryRangeChange,
801
+ onOpenTimeline,
618
802
  onOpenSource,
619
803
  onToggleReplicaSets,
620
804
  renderOverviewIssues,
621
805
  hasOverviewIssues,
622
806
  }: {
623
- app: AppRow
624
- activeView: CanonicalApplicationView
625
- onViewChange: (view: CanonicalApplicationView) => void
626
- workloads: AppWorkload[]
627
- ready: number
628
- desired: number
629
- versions: string[]
630
- topology: Topology | null
631
- topologyLoading?: boolean
632
- deploymentLayer: DeploymentTopologyLayer | null
633
- deploymentSource?: AppSourceRef
634
- topologyAvailable: boolean
635
- focusNodeId?: string
636
- focusedOwnerId: WorkloadFocus
637
- colorByWorkload: Map<string, number> | null
638
- onNodeClick: (node: TopologyNode) => void
639
- onNodeHover: (node: TopologyNode | null) => void
640
- onFocusWorkload: (owner: WorkloadFocus) => void
641
- onSelectWorkload: (workload: AppWorkload) => void
642
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
643
- history?: AppHistory
644
- historyLoading?: boolean
645
- onOpenSource?: (source: AppSourceRef) => void
646
- onToggleReplicaSets: (ownerID: string) => void
647
- renderOverviewIssues?: () => ReactNode
648
- hasOverviewIssues?: boolean
807
+ app: AppRow;
808
+ activeView: CanonicalApplicationView;
809
+ costSelected: boolean;
810
+ onViewChange: (view: CanonicalApplicationView) => void;
811
+ onCostViewChange: () => void;
812
+ renderCostView?: ApplicationDetailProps["renderCostView"];
813
+ workloads: AppWorkload[];
814
+ ready: number;
815
+ desired: number;
816
+ versions: string[];
817
+ topology: Topology | null;
818
+ topologyLoading?: boolean;
819
+ deploymentLayer: DeploymentTopologyLayer | null;
820
+ deploymentSource?: AppSourceRef;
821
+ topologyAvailable: boolean;
822
+ focusNodeId?: string;
823
+ focusedOwnerId: WorkloadFocus;
824
+ colorByWorkload: Map<string, number> | null;
825
+ onNodeClick: (node: TopologyNode) => void;
826
+ onNodeHover: (node: TopologyNode | null) => void;
827
+ onFocusWorkload: (owner: WorkloadFocus) => void;
828
+ onSelectWorkload: (
829
+ workload: AppWorkload,
830
+ options?: AppWorkloadSelectionOptions,
831
+ ) => void;
832
+ onNavigateToResource?: (resource: {
833
+ kind: string;
834
+ namespace: string;
835
+ name: string;
836
+ group?: string;
837
+ }) => void;
838
+ history?: AppHistory;
839
+ historyLoading?: boolean;
840
+ historyItems?: ApplicationHistoryItem[];
841
+ historyRuntimeLoading?: boolean;
842
+ historyRuntimeError?: boolean;
843
+ historyMode?: "local" | "retained";
844
+ historyRange?: ApplicationHistoryRange;
845
+ historyRangeOptions?: Array<{
846
+ value: ApplicationHistoryRange;
847
+ label: string;
848
+ }>;
849
+ historyCoverageRecordCount?: number;
850
+ historyRuntimeLimited?: boolean;
851
+ onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
852
+ onOpenTimeline?: (timestamp?: string) => void;
853
+ onOpenSource?: (source: AppSourceRef) => void;
854
+ onToggleReplicaSets: (ownerID: string) => void;
855
+ renderOverviewIssues?: () => ReactNode;
856
+ hasOverviewIssues?: boolean;
649
857
  }) {
650
- const historyCount =
651
- (history?.anchors?.length ?? 0) +
652
- (history?.incidents?.length ?? app.events?.length ?? 0);
653
858
  return (
654
859
  <div className="flex h-full min-h-0 flex-col">
655
860
  <ApplicationViewTabs
656
861
  activeView={activeView}
657
- historyCount={historyCount}
862
+ costSelected={costSelected}
863
+ costAvailable={Boolean(renderCostView)}
658
864
  onChange={onViewChange}
865
+ onCostChange={onCostViewChange}
659
866
  />
660
- {activeView === "overview" && (
867
+ {costSelected && renderCostView && (
868
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
869
+ {renderCostView({ app, workloads, onSelectWorkload })}
870
+ </div>
871
+ )}
872
+ {!costSelected && activeView === "overview" && (
661
873
  <ApplicationOverview
662
874
  app={app}
663
875
  workloads={workloads}
@@ -673,7 +885,7 @@ function ApplicationWorkspace({
673
885
  hasOverviewIssues={hasOverviewIssues}
674
886
  />
675
887
  )}
676
- {activeView === "topology" && (
888
+ {!costSelected && activeView === "topology" && (
677
889
  <ApplicationTopology
678
890
  topology={topology}
679
891
  loading={topologyLoading}
@@ -692,13 +904,23 @@ function ApplicationWorkspace({
692
904
  onToggleReplicaSets={onToggleReplicaSets}
693
905
  />
694
906
  )}
695
- {activeView === "history" && (
907
+ {!costSelected && activeView === "history" && (
696
908
  <ApplicationHistoryView
697
909
  history={history}
698
- loading={historyLoading}
699
- fallbackEvents={app.events ?? []}
910
+ sourceLoading={historyLoading}
911
+ items={historyItems}
912
+ runtimeLoading={historyRuntimeLoading}
913
+ runtimeError={historyRuntimeError}
914
+ mode={historyMode}
915
+ range={historyRange}
916
+ rangeOptions={historyRangeOptions}
917
+ coverageRecordCount={historyCoverageRecordCount}
918
+ runtimeLimited={historyRuntimeLimited}
700
919
  workloads={workloads}
701
920
  onSelectWorkload={onSelectWorkload}
921
+ onNavigateToResource={onNavigateToResource}
922
+ onRangeChange={onHistoryRangeChange}
923
+ onOpenTimeline={onOpenTimeline}
702
924
  onOpenSource={onOpenSource}
703
925
  />
704
926
  )}
@@ -708,12 +930,16 @@ function ApplicationWorkspace({
708
930
 
709
931
  function ApplicationViewTabs({
710
932
  activeView,
711
- historyCount,
933
+ costSelected,
934
+ costAvailable,
712
935
  onChange,
936
+ onCostChange,
713
937
  }: {
714
938
  activeView: CanonicalApplicationView;
715
- historyCount: number;
939
+ costSelected: boolean;
940
+ costAvailable: boolean;
716
941
  onChange: (view: CanonicalApplicationView) => void;
942
+ onCostChange: () => void;
717
943
  }) {
718
944
  return (
719
945
  <div
@@ -723,9 +949,7 @@ function ApplicationViewTabs({
723
949
  >
724
950
  <div className="flex min-w-0 gap-1 overflow-x-auto">
725
951
  {APPLICATION_VIEWS.map((view) => {
726
- const active = view.id === activeView;
727
- const badge =
728
- view.id === "history" && historyCount > 0 ? historyCount : null;
952
+ const active = !costSelected && view.id === activeView;
729
953
  return (
730
954
  <button
731
955
  key={view.id}
@@ -741,14 +965,26 @@ function ApplicationViewTabs({
741
965
  )}
742
966
  >
743
967
  {view.label}
744
- {badge !== null && (
745
- <span className="rounded bg-theme-hover px-1.5 py-0.5 text-[10px] font-semibold text-theme-text-tertiary">
746
- {badge}
747
- </span>
748
- )}
749
968
  </button>
750
969
  );
751
970
  })}
971
+ {costAvailable && (
972
+ <button
973
+ type="button"
974
+ role="tab"
975
+ aria-selected={costSelected}
976
+ onClick={onCostChange}
977
+ className={clsx(
978
+ "flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
979
+ costSelected
980
+ ? "border-skyhook-500 text-theme-text-primary"
981
+ : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
982
+ )}
983
+ >
984
+ <DollarSign className="h-4 w-4" />
985
+ Cost
986
+ </button>
987
+ )}
752
988
  </div>
753
989
  </div>
754
990
  );
@@ -778,60 +1014,88 @@ function ApplicationOverview({
778
1014
  renderOverviewIssues,
779
1015
  hasOverviewIssues,
780
1016
  }: {
781
- app: AppRow
782
- workloads: AppWorkload[]
783
- ready: number
784
- desired: number
785
- versions: string[]
786
- onSelectWorkload: (workload: AppWorkload) => void
787
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
788
- history?: AppHistory
789
- onSelectHistory: () => void
790
- onOpenSource?: (source: AppSourceRef) => void
791
- renderOverviewIssues?: () => ReactNode
792
- hasOverviewIssues?: boolean
1017
+ app: AppRow;
1018
+ workloads: AppWorkload[];
1019
+ ready: number;
1020
+ desired: number;
1021
+ versions: string[];
1022
+ onSelectWorkload: (workload: AppWorkload) => void;
1023
+ onNavigateToResource?: (resource: {
1024
+ kind: string;
1025
+ namespace: string;
1026
+ name: string;
1027
+ group?: string;
1028
+ }) => void;
1029
+ history?: AppHistory;
1030
+ onSelectHistory: () => void;
1031
+ onOpenSource?: (source: AppSourceRef) => void;
1032
+ renderOverviewIssues?: () => ReactNode;
1033
+ hasOverviewIssues?: boolean;
793
1034
  }) {
794
- const rel = app.relationships
795
- const hasEntrypoints = Boolean(rel && (relationshipRefs(rel, 'service').length > 0 || relationshipRefs(rel, 'ingress').length > 0 || relationshipRefs(rel, 'route').length > 0))
796
- const hasDependencies = Boolean(rel && (
797
- relationshipRefs(rel, 'config').length > 0 ||
798
- relationshipRefs(rel, 'scaler').length > 0 ||
799
- relationshipRefs(rel, 'storage').length > 0 ||
800
- relationshipRefs(rel, 'pdb').length > 0 ||
801
- relationshipRefs(rel, 'networkPolicy').length > 0
802
- ))
1035
+ const rel = app.relationships;
1036
+ const hasEntrypoints = Boolean(
1037
+ rel &&
1038
+ (relationshipRefs(rel, "service").length > 0 ||
1039
+ relationshipRefs(rel, "ingress").length > 0 ||
1040
+ relationshipRefs(rel, "route").length > 0),
1041
+ );
1042
+ const hasDependencies = Boolean(
1043
+ rel &&
1044
+ (relationshipRefs(rel, "config").length > 0 ||
1045
+ relationshipRefs(rel, "scaler").length > 0 ||
1046
+ relationshipRefs(rel, "storage").length > 0 ||
1047
+ relationshipRefs(rel, "pdb").length > 0 ||
1048
+ relationshipRefs(rel, "networkPolicy").length > 0),
1049
+ );
803
1050
  const composition = classCompositionOf(app)
804
1051
  .map(({ cls, count }) => `${count} ${cls}`)
805
- .join(' / ')
806
- const issues = useMemo(() => buildAppIssues(workloads, app.events ?? []), [workloads, app.events])
1052
+ .join(" / ");
1053
+ const issues = useMemo(
1054
+ () => buildAppIssues(workloads, app.events ?? []),
1055
+ [workloads, app.events],
1056
+ );
807
1057
  const currentIssuesPresent = renderOverviewIssues
808
- ? hasOverviewIssues ?? issues.length > 0
809
- : issues.length > 0
810
- const batchActivity = useMemo(() => batchActivityForApp(app), [app])
811
- const batchStats = batchOverviewStats(batchActivity)
812
- const pureBatch = workloadClassOf(app.workload_class) === 'job'
813
- const workloadComposition = workloadKindComposition(workloads)
814
- const latestChange = history?.summary?.state === 'change' ? history.summary : undefined
815
- const runtimeHealth = healthOf(app.runtimeHealth ?? worstHealth(workloads.map((workload) => workload.health)))
816
- const hasDeliveryStatus = Boolean(app.sourceStatus?.sync || app.sourceStatus?.health)
817
- const extraFactCount = Number(Boolean(latestChange)) + Number(hasDeliveryStatus)
1058
+ ? (hasOverviewIssues ?? issues.length > 0)
1059
+ : issues.length > 0;
1060
+ const batchActivity = useMemo(() => batchActivityForApp(app), [app]);
1061
+ const batchStats = batchOverviewStats(batchActivity);
1062
+ const pureBatch = workloadClassOf(app.workload_class) === "job";
1063
+ const workloadComposition = workloadKindComposition(workloads);
1064
+ const latestChange =
1065
+ history?.summary?.state === "change" ? history.summary : undefined;
1066
+ const runtimeHealth = healthOf(
1067
+ app.runtimeHealth ??
1068
+ worstHealth(workloads.map((workload) => workload.health)),
1069
+ );
1070
+ const hasDeliveryStatus = Boolean(
1071
+ app.sourceStatus?.sync || app.sourceStatus?.health,
1072
+ );
1073
+ const extraFactCount =
1074
+ Number(Boolean(latestChange)) + Number(hasDeliveryStatus);
818
1075
  const factGrid = pureBatch
819
1076
  ? extraFactCount === 2
820
- ? '2xl:grid-cols-6'
1077
+ ? "2xl:grid-cols-6"
821
1078
  : extraFactCount === 1
822
- ? '2xl:grid-cols-5'
823
- : '2xl:grid-cols-4'
1079
+ ? "2xl:grid-cols-5"
1080
+ : "2xl:grid-cols-4"
824
1081
  : extraFactCount === 2
825
- ? '2xl:grid-cols-5'
1082
+ ? "2xl:grid-cols-5"
826
1083
  : extraFactCount === 1
827
- ? '2xl:grid-cols-4'
828
- : '2xl:grid-cols-3'
1084
+ ? "2xl:grid-cols-4"
1085
+ : "2xl:grid-cols-3";
829
1086
 
830
1087
  return (
831
1088
  <div className="min-h-0 flex-1 overflow-auto">
832
1089
  <div className="grid w-full max-w-[2400px] gap-4 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
833
1090
  <div className="min-w-0 space-y-4">
834
- {renderOverviewIssues ? renderOverviewIssues() : <ApplicationNow issues={issues} onSelectWorkload={onSelectWorkload} />}
1091
+ {renderOverviewIssues ? (
1092
+ renderOverviewIssues()
1093
+ ) : (
1094
+ <ApplicationNow
1095
+ issues={issues}
1096
+ onSelectWorkload={onSelectWorkload}
1097
+ />
1098
+ )}
835
1099
  <ApplicationLatestHistory
836
1100
  history={history}
837
1101
  sourceRef={app.sourceRef}
@@ -839,29 +1103,67 @@ function ApplicationOverview({
839
1103
  onSelectHistory={onSelectHistory}
840
1104
  onOpenSource={onOpenSource}
841
1105
  />
842
- <div className={clsx('grid gap-3 md:grid-cols-2', factGrid)}>
1106
+ <div className={clsx("grid gap-3 md:grid-cols-2", factGrid)}>
843
1107
  <ApplicationFact
844
1108
  label="Runtime"
845
- value={pureBatch ? batchRuntimeForApp(app).label : HEALTH_META[runtimeHealth].label}
846
- detail={pureBatch ? batchStats.activeDetail : desired > 0 ? `${ready}/${desired} ready` : 'No desired replicas'}
1109
+ value={
1110
+ pureBatch
1111
+ ? batchRuntimeForApp(app).label
1112
+ : HEALTH_META[runtimeHealth].label
1113
+ }
1114
+ detail={
1115
+ pureBatch
1116
+ ? batchStats.activeDetail
1117
+ : desired > 0
1118
+ ? `${ready}/${desired} ready`
1119
+ : "No desired replicas"
1120
+ }
847
1121
  />
848
1122
  {hasDeliveryStatus && (
849
1123
  <ApplicationFact
850
1124
  label="Delivery"
851
1125
  value={<SourceDeliveryStatus status={app.sourceStatus!} />}
852
- detail={app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined}
1126
+ detail={
1127
+ app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined
1128
+ }
853
1129
  />
854
1130
  )}
855
1131
  {pureBatch ? (
856
1132
  <>
857
- <ApplicationFact label="Batch resources" value={String(workloads.length)} detail={workloadComposition || 'No workloads'} />
858
- <ApplicationFact label="Active runs" value={batchStats.activeValue} detail={batchStats.activeDetail} />
859
- <ApplicationFact label="Retained runs" value={batchStats.retainedValue} detail="Kubernetes-retained history" />
1133
+ <ApplicationFact
1134
+ label="Batch resources"
1135
+ value={String(workloads.length)}
1136
+ detail={workloadComposition || "No workloads"}
1137
+ />
1138
+ <ApplicationFact
1139
+ label="Active runs"
1140
+ value={batchStats.activeValue}
1141
+ detail={batchStats.activeDetail}
1142
+ />
1143
+ <ApplicationFact
1144
+ label="Retained runs"
1145
+ value={batchStats.retainedValue}
1146
+ detail="Kubernetes-retained history"
1147
+ />
860
1148
  </>
861
1149
  ) : (
862
1150
  <>
863
- <ApplicationFact label="Workloads" value={String(workloads.length)} detail={composition || 'No workloads'} />
864
- <ApplicationFact label="Version" value={app.appVersion || (versions.length === 1 ? versions[0] : versions.length > 1 ? `${versions.length} versions` : 'Unknown')} />
1151
+ <ApplicationFact
1152
+ label="Workloads"
1153
+ value={String(workloads.length)}
1154
+ detail={composition || "No workloads"}
1155
+ />
1156
+ <ApplicationFact
1157
+ label="Version"
1158
+ value={
1159
+ app.appVersion ||
1160
+ (versions.length === 1
1161
+ ? versions[0]
1162
+ : versions.length > 1
1163
+ ? `${versions.length} versions`
1164
+ : "Unknown")
1165
+ }
1166
+ />
865
1167
  </>
866
1168
  )}
867
1169
  {latestChange && (
@@ -887,9 +1189,19 @@ function ApplicationOverview({
887
1189
  </ApplicationPanel>
888
1190
  {!pureBatch && <ApplicationBatchOverview activity={batchActivity} />}
889
1191
  </div>
890
- <aside className={clsx('min-w-0 gap-4 xl:block xl:space-y-4', hasDependencies ? 'grid md:grid-cols-2' : 'max-w-xl xl:max-w-none')}>
1192
+ <aside
1193
+ className={clsx(
1194
+ "min-w-0 gap-4 xl:block xl:space-y-4",
1195
+ hasDependencies ? "grid md:grid-cols-2" : "max-w-xl xl:max-w-none",
1196
+ )}
1197
+ >
891
1198
  <ApplicationSourceProvenance app={app} onOpenSource={onOpenSource} />
892
- {hasDependencies && <ApplicationDependencies relationships={rel} onNavigateToResource={onNavigateToResource} />}
1199
+ {hasDependencies && (
1200
+ <ApplicationDependencies
1201
+ relationships={rel}
1202
+ onNavigateToResource={onNavigateToResource}
1203
+ />
1204
+ )}
893
1205
  </aside>
894
1206
  </div>
895
1207
  </div>
@@ -915,21 +1227,27 @@ function ApplicationSourceProvenance({
915
1227
  app,
916
1228
  onOpenSource,
917
1229
  }: {
918
- app: AppRow
919
- onOpenSource?: (source: AppSourceRef) => void
1230
+ app: AppRow;
1231
+ onOpenSource?: (source: AppSourceRef) => void;
920
1232
  }) {
921
1233
  if (app.sourceRef) {
922
- const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`
1234
+ const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`;
923
1235
  return (
924
1236
  <ApplicationPanel title="Deployment source">
925
1237
  <div className="space-y-3">
926
1238
  <div>
927
- <div className="text-xs font-semibold uppercase text-theme-text-tertiary">{sourceObjectLabel(app.sourceRef)}</div>
1239
+ <div className="text-xs font-semibold uppercase text-theme-text-tertiary">
1240
+ {sourceObjectLabel(app.sourceRef)}
1241
+ </div>
928
1242
  <Tooltip content={sourceName} delay={150}>
929
- <div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">{sourceName}</div>
1243
+ <div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">
1244
+ {sourceName}
1245
+ </div>
930
1246
  </Tooltip>
931
1247
  </div>
932
- {app.sourceStatus && <SourceDeliveryStatus status={app.sourceStatus} />}
1248
+ {app.sourceStatus && (
1249
+ <SourceDeliveryStatus status={app.sourceStatus} />
1250
+ )}
933
1251
  {onOpenSource && (
934
1252
  <button
935
1253
  type="button"
@@ -942,7 +1260,7 @@ function ApplicationSourceProvenance({
942
1260
  )}
943
1261
  </div>
944
1262
  </ApplicationPanel>
945
- )
1263
+ );
946
1264
  }
947
1265
 
948
1266
  if (app.sourceConflict) {
@@ -950,37 +1268,58 @@ function ApplicationSourceProvenance({
950
1268
  <ApplicationPanel title="Deployment source">
951
1269
  <div className="space-y-2">
952
1270
  <div className="flex items-center gap-2">
953
- <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
954
- <span className="text-sm font-semibold text-theme-text-primary">Multiple deployment sources detected</span>
1271
+ <AlertTriangle
1272
+ className="h-4 w-4 shrink-0 text-amber-500"
1273
+ aria-hidden
1274
+ />
1275
+ <span className="text-sm font-semibold text-theme-text-primary">
1276
+ Multiple deployment sources detected
1277
+ </span>
955
1278
  </div>
956
- <p className="text-sm text-theme-text-secondary">Workloads in this application do not share one deployment manager.</p>
1279
+ <p className="text-sm text-theme-text-secondary">
1280
+ Workloads in this application do not share one deployment manager.
1281
+ </p>
957
1282
  </div>
958
1283
  </ApplicationPanel>
959
- )
1284
+ );
960
1285
  }
961
1286
 
962
- const inferred = app.identity?.confidence === 'medium'
963
- const identityLabel = app.identity?.source ? appSourceLabel(app.identity.source) : undefined
964
- const tierLabel = !app.identity && app.tier ? overlayProvenance(app.tier) : undefined
1287
+ const inferred = app.identity?.confidence === "medium";
1288
+ const identityLabel = app.identity?.source
1289
+ ? appSourceLabel(app.identity.source)
1290
+ : undefined;
1291
+ const tierLabel =
1292
+ !app.identity && app.tier ? overlayProvenance(app.tier) : undefined;
965
1293
  return (
966
1294
  <ApplicationPanel title="Application identity">
967
1295
  <div className="space-y-2">
968
1296
  <div className="flex items-center gap-2">
969
- {inferred && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />}
1297
+ {inferred && (
1298
+ <AlertTriangle
1299
+ className="h-4 w-4 shrink-0 text-amber-500"
1300
+ aria-hidden
1301
+ />
1302
+ )}
970
1303
  <span className="text-sm font-semibold text-theme-text-primary">
971
1304
  {identityLabel
972
1305
  ? inferred
973
- ? 'Inferred application boundary'
1306
+ ? "Inferred application boundary"
974
1307
  : `Identified by ${identityLabel}`
975
1308
  : tierLabel
976
1309
  ? `Grouped by ${tierLabel} metadata`
977
- : 'Grouped from Kubernetes ownership'}
1310
+ : "Grouped from Kubernetes ownership"}
978
1311
  </span>
979
1312
  </div>
980
- {inferred && identityLabel && <p className="text-sm text-theme-text-secondary">Matched using {identityLabel}.</p>}
1313
+ {inferred && identityLabel && (
1314
+ <p className="text-sm text-theme-text-secondary">
1315
+ Matched using {identityLabel}.
1316
+ </p>
1317
+ )}
981
1318
  {app.identity?.evidence && (
982
1319
  <Tooltip content={app.identity.evidence} delay={150}>
983
- <div className="truncate font-mono text-xs text-theme-text-tertiary">{app.identity.evidence}</div>
1320
+ <div className="truncate font-mono text-xs text-theme-text-tertiary">
1321
+ {app.identity.evidence}
1322
+ </div>
984
1323
  </Tooltip>
985
1324
  )}
986
1325
  </div>
@@ -992,17 +1331,22 @@ function ApplicationEntrypoints({
992
1331
  relationships,
993
1332
  onNavigateToResource,
994
1333
  }: {
995
- relationships: AppRow['relationships']
996
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1334
+ relationships: AppRow["relationships"];
1335
+ onNavigateToResource?: (resource: {
1336
+ kind: string;
1337
+ namespace: string;
1338
+ name: string;
1339
+ group?: string;
1340
+ }) => void;
997
1341
  }) {
998
- const serviceRefs = relationshipRefs(relationships, 'service')
999
- const ingressRefs = relationshipRefs(relationships, 'ingress')
1000
- const routeRefs = relationshipRefs(relationships, 'route')
1001
- const serviceCount = serviceRefs.length
1002
- const ingressCount = ingressRefs.length
1003
- const routeCount = routeRefs.length
1004
- const hasExternal = ingressCount + routeCount > 0
1005
- if (serviceCount + ingressCount + routeCount === 0) return null
1342
+ const serviceRefs = relationshipRefs(relationships, "service");
1343
+ const ingressRefs = relationshipRefs(relationships, "ingress");
1344
+ const routeRefs = relationshipRefs(relationships, "route");
1345
+ const serviceCount = serviceRefs.length;
1346
+ const ingressCount = ingressRefs.length;
1347
+ const routeCount = routeRefs.length;
1348
+ const hasExternal = ingressCount + routeCount > 0;
1349
+ if (serviceCount + ingressCount + routeCount === 0) return null;
1006
1350
 
1007
1351
  return (
1008
1352
  <ApplicationPanel title="Entrypoints">
@@ -1054,7 +1398,11 @@ function ApplicationEntrypoints({
1054
1398
  refs={ingressRefs}
1055
1399
  onNavigateToResource={onNavigateToResource}
1056
1400
  />
1057
- <ApplicationRelatedNameGroup label="Routes" refs={routeRefs} onNavigateToResource={onNavigateToResource} />
1401
+ <ApplicationRelatedNameGroup
1402
+ label="Routes"
1403
+ refs={routeRefs}
1404
+ onNavigateToResource={onNavigateToResource}
1405
+ />
1058
1406
  </div>
1059
1407
  </div>
1060
1408
  </ApplicationPanel>
@@ -1065,24 +1413,67 @@ function ApplicationDependencies({
1065
1413
  relationships,
1066
1414
  onNavigateToResource,
1067
1415
  }: {
1068
- relationships: AppRow['relationships']
1069
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1416
+ relationships: AppRow["relationships"];
1417
+ onNavigateToResource?: (resource: {
1418
+ kind: string;
1419
+ namespace: string;
1420
+ name: string;
1421
+ group?: string;
1422
+ }) => void;
1070
1423
  }) {
1071
- const configs = relationshipRefs(relationships, 'config')
1072
- const scalers = relationshipRefs(relationships, 'scaler')
1073
- const storage = relationshipRefs(relationships, 'storage')
1074
- const pdbs = relationshipRefs(relationships, 'pdb')
1075
- const networkPolicies = relationshipRefs(relationships, 'networkPolicy')
1076
- if (configs.length + scalers.length + storage.length + pdbs.length + networkPolicies.length === 0) return null
1424
+ const configs = relationshipRefs(relationships, "config");
1425
+ const scalers = relationshipRefs(relationships, "scaler");
1426
+ const storage = relationshipRefs(relationships, "storage");
1427
+ const pdbs = relationshipRefs(relationships, "pdb");
1428
+ const networkPolicies = relationshipRefs(relationships, "networkPolicy");
1429
+ if (
1430
+ configs.length +
1431
+ scalers.length +
1432
+ storage.length +
1433
+ pdbs.length +
1434
+ networkPolicies.length ===
1435
+ 0
1436
+ )
1437
+ return null;
1077
1438
 
1078
1439
  return (
1079
1440
  <ApplicationPanel title="Dependencies">
1080
1441
  <div className="space-y-3">
1081
- <ApplicationDependencyRow label="Configuration" refs={configs} totalCount={relationships?.configs} detail="ConfigMaps and Secrets referenced by app workloads." onNavigateToResource={onNavigateToResource} />
1082
- <ApplicationDependencyRow label="Autoscaling" refs={scalers} totalCount={relationships?.scalers} detail="Autoscalers controlling app workloads." onNavigateToResource={onNavigateToResource} />
1083
- <ApplicationDependencyRow label="Storage" refs={storage} totalCount={relationships?.storage} detail="PersistentVolumeClaims mounted by app workloads." onNavigateToResource={onNavigateToResource} />
1084
- <ApplicationDependencyRow label="Availability policy" refs={pdbs} totalCount={relationships?.pdbs} detail="PodDisruptionBudgets protecting app workloads." onNavigateToResource={onNavigateToResource} />
1085
- <ApplicationDependencyRow label="Network policy" refs={networkPolicies} totalCount={relationships?.networkPolicies} detail="Network policies selecting app workloads." onNavigateToResource={onNavigateToResource} />
1442
+ <ApplicationDependencyRow
1443
+ label="Configuration"
1444
+ refs={configs}
1445
+ totalCount={relationships?.configs}
1446
+ detail="ConfigMaps and Secrets referenced by app workloads."
1447
+ onNavigateToResource={onNavigateToResource}
1448
+ />
1449
+ <ApplicationDependencyRow
1450
+ label="Autoscaling"
1451
+ refs={scalers}
1452
+ totalCount={relationships?.scalers}
1453
+ detail="Autoscalers controlling app workloads."
1454
+ onNavigateToResource={onNavigateToResource}
1455
+ />
1456
+ <ApplicationDependencyRow
1457
+ label="Storage"
1458
+ refs={storage}
1459
+ totalCount={relationships?.storage}
1460
+ detail="PersistentVolumeClaims mounted by app workloads."
1461
+ onNavigateToResource={onNavigateToResource}
1462
+ />
1463
+ <ApplicationDependencyRow
1464
+ label="Availability policy"
1465
+ refs={pdbs}
1466
+ totalCount={relationships?.pdbs}
1467
+ detail="PodDisruptionBudgets protecting app workloads."
1468
+ onNavigateToResource={onNavigateToResource}
1469
+ />
1470
+ <ApplicationDependencyRow
1471
+ label="Network policy"
1472
+ refs={networkPolicies}
1473
+ totalCount={relationships?.networkPolicies}
1474
+ detail="Network policies selecting app workloads."
1475
+ onNavigateToResource={onNavigateToResource}
1476
+ />
1086
1477
  </div>
1087
1478
  </ApplicationPanel>
1088
1479
  );
@@ -1095,14 +1486,19 @@ function ApplicationDependencyRow({
1095
1486
  detail,
1096
1487
  onNavigateToResource,
1097
1488
  }: {
1098
- label: string
1099
- refs: ResourceRef[]
1100
- totalCount?: number
1101
- detail: string
1102
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1489
+ label: string;
1490
+ refs: ResourceRef[];
1491
+ totalCount?: number;
1492
+ detail: string;
1493
+ onNavigateToResource?: (resource: {
1494
+ kind: string;
1495
+ namespace: string;
1496
+ name: string;
1497
+ group?: string;
1498
+ }) => void;
1103
1499
  }) {
1104
- if (refs.length === 0) return null
1105
- const count = Math.max(totalCount ?? refs.length, refs.length)
1500
+ if (refs.length === 0) return null;
1501
+ const count = Math.max(totalCount ?? refs.length, refs.length);
1106
1502
  return (
1107
1503
  <div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
1108
1504
  <div className="flex items-baseline justify-between gap-3">
@@ -1114,7 +1510,13 @@ function ApplicationDependencyRow({
1114
1510
  </span>
1115
1511
  </div>
1116
1512
  <div className="mt-1 text-xs text-theme-text-tertiary">{detail}</div>
1117
- <ApplicationRelatedNameGroup label={label} refs={refs} totalCount={count} onNavigateToResource={onNavigateToResource} hideLabel />
1513
+ <ApplicationRelatedNameGroup
1514
+ label={label}
1515
+ refs={refs}
1516
+ totalCount={count}
1517
+ onNavigateToResource={onNavigateToResource}
1518
+ hideLabel
1519
+ />
1118
1520
  </div>
1119
1521
  );
1120
1522
  }
@@ -1126,55 +1528,83 @@ function ApplicationRelatedNameGroup({
1126
1528
  onNavigateToResource,
1127
1529
  hideLabel,
1128
1530
  }: {
1129
- label: string
1130
- refs?: ResourceRef[]
1131
- totalCount?: number
1132
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1133
- hideLabel?: boolean
1531
+ label: string;
1532
+ refs?: ResourceRef[];
1533
+ totalCount?: number;
1534
+ onNavigateToResource?: (resource: {
1535
+ kind: string;
1536
+ namespace: string;
1537
+ name: string;
1538
+ group?: string;
1539
+ }) => void;
1540
+ hideLabel?: boolean;
1134
1541
  }) {
1135
- if (!refs || refs.length === 0) return null
1542
+ if (!refs || refs.length === 0) return null;
1136
1543
 
1137
- const visible = refs.slice(0, 12)
1138
- const count = Math.max(totalCount ?? refs.length, refs.length)
1139
- const overflow = Math.max(0, count - visible.length)
1544
+ const visible = refs.slice(0, 12);
1545
+ const count = Math.max(totalCount ?? refs.length, refs.length);
1546
+ const overflow = Math.max(0, count - visible.length);
1140
1547
  return (
1141
- <div className={hideLabel ? 'mt-2' : undefined}>
1142
- {!hideLabel && <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}{count > 1 ? ` (${count})` : ''}</div>}
1548
+ <div className={hideLabel ? "mt-2" : undefined}>
1549
+ {!hideLabel && (
1550
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">
1551
+ {label}
1552
+ {count > 1 ? ` (${count})` : ""}
1553
+ </div>
1554
+ )}
1143
1555
  <div className="flex flex-wrap gap-1.5">
1144
1556
  {visible.map((ref) => (
1145
1557
  <ResourceRefBadge
1146
- key={`${ref.group || ''}/${ref.kind}/${ref.namespace}/${ref.name}`}
1558
+ key={`${ref.group || ""}/${ref.kind}/${ref.namespace}/${ref.name}`}
1147
1559
  resourceRef={ref}
1148
- onClick={onNavigateToResource ? (clicked) => onNavigateToResource(refToSelectedResource(clicked)) : undefined}
1560
+ onClick={
1561
+ onNavigateToResource
1562
+ ? (clicked) =>
1563
+ onNavigateToResource(refToSelectedResource(clicked))
1564
+ : undefined
1565
+ }
1149
1566
  />
1150
1567
  ))}
1151
- {overflow > 0 && <span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>}
1568
+ {overflow > 0 && (
1569
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>
1570
+ )}
1152
1571
  </div>
1153
1572
  </div>
1154
1573
  );
1155
1574
  }
1156
1575
 
1157
- type RelationshipGroup = 'service' | 'ingress' | 'route' | 'config' | 'scaler' | 'storage' | 'pdb' | 'networkPolicy'
1158
-
1159
- function relationshipRefs(relationships: AppRow['relationships'] | undefined, group: RelationshipGroup): ResourceRef[] {
1160
- if (!relationships) return []
1576
+ type RelationshipGroup =
1577
+ | "service"
1578
+ | "ingress"
1579
+ | "route"
1580
+ | "config"
1581
+ | "scaler"
1582
+ | "storage"
1583
+ | "pdb"
1584
+ | "networkPolicy";
1585
+
1586
+ function relationshipRefs(
1587
+ relationships: AppRow["relationships"] | undefined,
1588
+ group: RelationshipGroup,
1589
+ ): ResourceRef[] {
1590
+ if (!relationships) return [];
1161
1591
  switch (group) {
1162
- case 'service':
1163
- return relationships.serviceRefs ?? []
1164
- case 'ingress':
1165
- return relationships.ingressRefs ?? []
1166
- case 'route':
1167
- return relationships.routeRefs ?? []
1168
- case 'config':
1169
- return relationships.configRefs ?? []
1170
- case 'scaler':
1171
- return relationships.scalerRefs ?? []
1172
- case 'storage':
1173
- return relationships.storageRefs ?? []
1174
- case 'pdb':
1175
- return relationships.pdbRefs ?? []
1176
- case 'networkPolicy':
1177
- return relationships.networkPolicyRefs ?? []
1592
+ case "service":
1593
+ return relationships.serviceRefs ?? [];
1594
+ case "ingress":
1595
+ return relationships.ingressRefs ?? [];
1596
+ case "route":
1597
+ return relationships.routeRefs ?? [];
1598
+ case "config":
1599
+ return relationships.configRefs ?? [];
1600
+ case "scaler":
1601
+ return relationships.scalerRefs ?? [];
1602
+ case "storage":
1603
+ return relationships.storageRefs ?? [];
1604
+ case "pdb":
1605
+ return relationships.pdbRefs ?? [];
1606
+ case "networkPolicy":
1607
+ return relationships.networkPolicyRefs ?? [];
1178
1608
  }
1179
1609
  }
1180
1610
 
@@ -1182,10 +1612,10 @@ function ApplicationNow({
1182
1612
  issues,
1183
1613
  onSelectWorkload,
1184
1614
  }: {
1185
- issues: AppIssue[]
1186
- onSelectWorkload: (workload: AppWorkload) => void
1615
+ issues: AppIssue[];
1616
+ onSelectWorkload: (workload: AppWorkload) => void;
1187
1617
  }) {
1188
- if (issues.length === 0) return null
1618
+ if (issues.length === 0) return null;
1189
1619
 
1190
1620
  const top = issues[0];
1191
1621
  return (
@@ -1398,27 +1828,31 @@ function ApplicationLatestHistory({
1398
1828
  onSelectHistory,
1399
1829
  onOpenSource,
1400
1830
  }: {
1401
- history?: AppHistory
1402
- sourceRef?: AppSourceRef
1403
- showIncidentPreview?: boolean
1404
- onSelectHistory: () => void
1405
- onOpenSource?: (source: AppSourceRef) => void
1831
+ history?: AppHistory;
1832
+ sourceRef?: AppSourceRef;
1833
+ showIncidentPreview?: boolean;
1834
+ onSelectHistory: () => void;
1835
+ onOpenSource?: (source: AppSourceRef) => void;
1406
1836
  }) {
1407
- const summary = history?.summary
1408
- const showIncident = summary?.state === 'incident' && showIncidentPreview
1409
- if (!summary || !showIncident) return null
1410
- const resolvedSource = sourceRef ?? history?.sourceRef
1411
- const tone = CHIP_TONE.amber
1837
+ const summary = history?.summary;
1838
+ const showIncident = summary?.state === "incident" && showIncidentPreview;
1839
+ if (!summary || !showIncident) return null;
1840
+ const resolvedSource = sourceRef ?? history?.sourceRef;
1841
+ const tone = CHIP_TONE.amber;
1412
1842
 
1413
1843
  return (
1414
1844
  <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
1415
1845
  <div className="flex flex-wrap items-start gap-3">
1416
- <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}>
1846
+ <span
1847
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}
1848
+ >
1417
1849
  <AlertTriangle className="h-4 w-4" aria-hidden />
1418
1850
  </span>
1419
1851
  <div className="min-w-0 flex-1">
1420
1852
  <div className="flex flex-wrap items-center gap-2">
1421
- <h2 className="text-sm font-semibold text-theme-text-primary">{summary.title}</h2>
1853
+ <h2 className="text-sm font-semibold text-theme-text-primary">
1854
+ {summary.title}
1855
+ </h2>
1422
1856
  <span className={`${CHIP} ${tone}`}>Latest incident</span>
1423
1857
  </div>
1424
1858
  {summary.detail && (
@@ -1434,7 +1868,11 @@ function ApplicationLatestHistory({
1434
1868
  </div>
1435
1869
  <div className="flex shrink-0 items-center gap-2">
1436
1870
  {resolvedSource && !sourceRef && onOpenSource && (
1437
- <button type="button" onClick={() => onOpenSource(resolvedSource)} className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover">
1871
+ <button
1872
+ type="button"
1873
+ onClick={() => onOpenSource(resolvedSource)}
1874
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1875
+ >
1438
1876
  {sourceLinkLabel(resolvedSource)}
1439
1877
  </button>
1440
1878
  )}
@@ -1457,29 +1895,53 @@ function ApplicationLatestChangeFact({
1457
1895
  onSelectHistory,
1458
1896
  onOpenSource,
1459
1897
  }: {
1460
- summary: NonNullable<AppHistory['summary']>
1461
- sourceRef?: AppSourceRef
1462
- onSelectHistory: () => void
1463
- onOpenSource?: (source: AppSourceRef) => void
1898
+ summary: NonNullable<AppHistory["summary"]>;
1899
+ sourceRef?: AppSourceRef;
1900
+ onSelectHistory: () => void;
1901
+ onOpenSource?: (source: AppSourceRef) => void;
1464
1902
  }) {
1465
1903
  return (
1466
1904
  <div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm md:col-span-2 2xl:col-span-1">
1467
1905
  <div className="flex items-center justify-between gap-2">
1468
- <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Latest change</div>
1469
- <button type="button" onClick={onSelectHistory} className="shrink-0 text-xs font-medium text-accent-text hover:underline">History</button>
1906
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
1907
+ Latest change
1908
+ </div>
1909
+ <button
1910
+ type="button"
1911
+ onClick={onSelectHistory}
1912
+ className="shrink-0 text-xs font-medium text-accent-text hover:underline"
1913
+ >
1914
+ History
1915
+ </button>
1470
1916
  </div>
1471
- <div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">{summary.title}</div>
1472
- {summary.detail && <div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">{summary.detail}</div>}
1917
+ <div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">
1918
+ {summary.title}
1919
+ </div>
1920
+ {summary.detail && (
1921
+ <div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">
1922
+ {summary.detail}
1923
+ </div>
1924
+ )}
1473
1925
  {sourceRef && onOpenSource && (
1474
- <button type="button" onClick={() => onOpenSource(sourceRef)} className="mt-1 text-xs font-medium text-accent-text hover:underline">
1926
+ <button
1927
+ type="button"
1928
+ onClick={() => onOpenSource(sourceRef)}
1929
+ className="mt-1 text-xs font-medium text-accent-text hover:underline"
1930
+ >
1475
1931
  {sourceLinkLabel(sourceRef)}
1476
1932
  </button>
1477
1933
  )}
1478
1934
  </div>
1479
- )
1935
+ );
1480
1936
  }
1481
1937
 
1482
- function WorkloadsMatrix({ workloads, onSelectWorkload }: { workloads: AppWorkload[]; onSelectWorkload: (workload: AppWorkload) => void }) {
1938
+ function WorkloadsMatrix({
1939
+ workloads,
1940
+ onSelectWorkload,
1941
+ }: {
1942
+ workloads: AppWorkload[];
1943
+ onSelectWorkload: (workload: AppWorkload) => void;
1944
+ }) {
1483
1945
  if (workloads.length === 0) {
1484
1946
  return <EmptyState variant="inline" headline="No inspectable workloads." />;
1485
1947
  }
@@ -1499,8 +1961,11 @@ function WorkloadsMatrix({ workloads, onSelectWorkload }: { workloads: AppWorklo
1499
1961
  </thead>
1500
1962
  <tbody>
1501
1963
  {workloads.map((w) => {
1502
- const status = workloadRuntimeStatus(w)
1503
- const reason = w.reason === 'Completed' && w.kind !== 'Job' ? undefined : w.reason
1964
+ const status = workloadRuntimeStatus(w);
1965
+ const reason =
1966
+ w.reason === "Completed" && w.kind !== "Job"
1967
+ ? undefined
1968
+ : w.reason;
1504
1969
  return (
1505
1970
  <tr
1506
1971
  key={workloadKey(w)}
@@ -1514,10 +1979,18 @@ function WorkloadsMatrix({ workloads, onSelectWorkload }: { workloads: AppWorklo
1514
1979
  >
1515
1980
  {w.name}
1516
1981
  </button>
1517
- {reason && <div className="truncate text-xs text-theme-text-tertiary">{reason}</div>}
1982
+ {reason && (
1983
+ <div className="truncate text-xs text-theme-text-tertiary">
1984
+ {reason}
1985
+ </div>
1986
+ )}
1987
+ </td>
1988
+ <td className="px-2 py-2 text-theme-text-secondary">
1989
+ {w.kind}
1990
+ </td>
1991
+ <td className="px-2 py-2 text-theme-text-secondary">
1992
+ {workloadClassOf(w.workload_class)}
1518
1993
  </td>
1519
- <td className="px-2 py-2 text-theme-text-secondary">{w.kind}</td>
1520
- <td className="px-2 py-2 text-theme-text-secondary">{workloadClassOf(w.workload_class)}</td>
1521
1994
  <td className="px-2 py-2">
1522
1995
  <span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
1523
1996
  <StatusDot tone={mapHealthToTone(status.health)} />
@@ -1588,28 +2061,29 @@ function ApplicationTopology({
1588
2061
  onOpenSource,
1589
2062
  onToggleReplicaSets,
1590
2063
  }: {
1591
- topology: Topology | null
1592
- loading?: boolean
1593
- available: boolean
1594
- workloads: AppWorkload[]
1595
- colorByWorkload: Map<string, number> | null
1596
- focusNodeId?: string
1597
- focusedOwnerId: WorkloadFocus
1598
- onNodeClick: (node: TopologyNode) => void
1599
- onNodeHover: (node: TopologyNode | null) => void
1600
- onFocusWorkload: (owner: WorkloadFocus) => void
1601
- onSelectWorkload: (workload: AppWorkload) => void
1602
- deploymentLayer: DeploymentTopologyLayer | null
1603
- deploymentSource?: AppSourceRef
1604
- onOpenSource?: (source: AppSourceRef) => void
1605
- onToggleReplicaSets: (ownerID: string) => void
2064
+ topology: Topology | null;
2065
+ loading?: boolean;
2066
+ available: boolean;
2067
+ workloads: AppWorkload[];
2068
+ colorByWorkload: Map<string, number> | null;
2069
+ focusNodeId?: string;
2070
+ focusedOwnerId: WorkloadFocus;
2071
+ onNodeClick: (node: TopologyNode) => void;
2072
+ onNodeHover: (node: TopologyNode | null) => void;
2073
+ onFocusWorkload: (owner: WorkloadFocus) => void;
2074
+ onSelectWorkload: (workload: AppWorkload) => void;
2075
+ deploymentLayer: DeploymentTopologyLayer | null;
2076
+ deploymentSource?: AppSourceRef;
2077
+ onOpenSource?: (source: AppSourceRef) => void;
2078
+ onToggleReplicaSets: (ownerID: string) => void;
1606
2079
  }) {
1607
2080
  const hasSharedOrUnscopedNodes = useMemo(
1608
- () => topology?.nodes.some((node) => {
1609
- if (node.data?.deploymentMembership === 'source-only') return false
1610
- const stamp = ownershipOf(node.data)
1611
- return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1
1612
- }) ?? false,
2081
+ () =>
2082
+ topology?.nodes.some((node) => {
2083
+ if (node.data?.deploymentMembership === "source-only") return false;
2084
+ const stamp = ownershipOf(node.data);
2085
+ return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1;
2086
+ }) ?? false,
1613
2087
  [topology],
1614
2088
  );
1615
2089
 
@@ -1628,7 +2102,12 @@ function ApplicationTopology({
1628
2102
  focusedOwnerId={focusedOwnerId}
1629
2103
  onNodeHover={onNodeHover}
1630
2104
  onToggleReplicaSets={onToggleReplicaSets}
1631
- fitViewPadding={{ top: 0.08, right: 0.05, bottom: 0.08, left: '360px' }}
2105
+ fitViewPadding={{
2106
+ top: 0.08,
2107
+ right: 0.05,
2108
+ bottom: 0.08,
2109
+ left: "360px",
2110
+ }}
1632
2111
  />
1633
2112
  <TopologyWorkloadLegend
1634
2113
  workloads={workloads}
@@ -1658,202 +2137,402 @@ function ApplicationTopology({
1658
2137
 
1659
2138
  function ApplicationHistoryView({
1660
2139
  history,
1661
- loading,
1662
- fallbackEvents,
2140
+ sourceLoading,
2141
+ items: suppliedItems,
2142
+ runtimeLoading,
2143
+ runtimeError,
2144
+ mode,
2145
+ range,
2146
+ rangeOptions,
2147
+ coverageRecordCount,
2148
+ runtimeLimited,
1663
2149
  workloads,
1664
2150
  onSelectWorkload,
2151
+ onNavigateToResource,
2152
+ onRangeChange,
2153
+ onOpenTimeline,
1665
2154
  onOpenSource,
1666
2155
  }: {
1667
2156
  history?: AppHistory;
1668
- loading?: boolean;
1669
- fallbackEvents: NonNullable<AppRow["events"]>;
2157
+ sourceLoading?: boolean;
2158
+ items?: ApplicationHistoryItem[];
2159
+ runtimeLoading?: boolean;
2160
+ runtimeError?: boolean;
2161
+ mode?: "local" | "retained";
2162
+ range?: ApplicationHistoryRange;
2163
+ rangeOptions?: Array<{ value: ApplicationHistoryRange; label: string }>;
2164
+ coverageRecordCount?: number;
2165
+ runtimeLimited?: boolean;
1670
2166
  workloads: AppWorkload[];
1671
2167
  onSelectWorkload: (workload: AppWorkload) => void;
2168
+ onNavigateToResource?: (resource: ResourceRef) => void;
2169
+ onRangeChange?: (range: ApplicationHistoryRange) => void;
2170
+ onOpenTimeline?: (timestamp?: string) => void;
1672
2171
  onOpenSource?: (source: AppSourceRef) => void;
1673
2172
  }) {
1674
- const anchors = history?.anchors ?? [];
1675
- const incidents =
1676
- history?.incidents ??
1677
- fallbackEvents.map((event) => ({
1678
- severity: "warning",
1679
- title: event.object ? `${event.reason} on ${event.object}` : event.reason,
1680
- object: event.object,
1681
- message: event.message,
1682
- count: event.count,
1683
- firstSeen: event.firstSeen,
1684
- lastSeen: event.lastSeen,
1685
- }));
1686
- const empty = !loading && anchors.length === 0 && incidents.length === 0;
2173
+ const [category, setCategory] = useState<ApplicationHistoryCategory | "all">(
2174
+ "all",
2175
+ );
2176
+ const items = useMemo(
2177
+ () => suppliedItems ?? buildApplicationHistoryItems(history, []),
2178
+ [history, suppliedItems],
2179
+ );
2180
+ const availableCategories = useMemo(
2181
+ () => Array.from(new Set(items.map((item) => item.category))),
2182
+ [items],
2183
+ );
2184
+ useEffect(() => {
2185
+ if (category !== "all" && !availableCategories.includes(category))
2186
+ setCategory("all");
2187
+ }, [availableCategories, category]);
2188
+ const visibleItems = useMemo(
2189
+ () =>
2190
+ category === "all"
2191
+ ? items
2192
+ : items.filter((item) => item.category === category),
2193
+ [category, items],
2194
+ );
2195
+ const groups = useMemo(() => {
2196
+ const byDate = new Map<string, ApplicationHistoryItem[]>();
2197
+ for (const item of visibleItems) {
2198
+ const label = applicationHistoryDateLabel(item.timestamp);
2199
+ byDate.set(label, [...(byDate.get(label) ?? []), item]);
2200
+ }
2201
+ return [...byDate.entries()];
2202
+ }, [visibleItems]);
2203
+ const loading = sourceLoading || runtimeLoading;
2204
+ const empty = !loading && !runtimeError && items.length === 0;
2205
+ const hasRuntimeItems = items.some((item) => item.category !== "deployment");
1687
2206
 
1688
2207
  return (
1689
2208
  <div className="min-h-0 flex-1 overflow-auto">
1690
- <div className="w-full max-w-[2400px] space-y-4 p-4 sm:p-6">
1691
- {loading && (
1692
- <ApplicationPanel title="History">
1693
- <div className="text-sm text-theme-text-tertiary">
1694
- Loading application history...
1695
- </div>
1696
- </ApplicationPanel>
1697
- )}
1698
- {history?.partialSources && history.partialSources.length > 0 && (
1699
- <ApplicationPanel title="Partial history">
1700
- <div className="space-y-1 text-sm text-theme-text-secondary">
1701
- {history.partialSources.map((msg, idx) => (
1702
- <div key={`${msg}-${idx}`}>{msg}</div>
1703
- ))}
1704
- </div>
1705
- </ApplicationPanel>
1706
- )}
1707
- {incidents.length > 0 && (
1708
- <ApplicationPanel title="Current incidents">
1709
- <div className="divide-y divide-theme-border">
1710
- {incidents.map((incident, idx) => {
1711
- const workload = directWorkloadForEvent(
1712
- {
1713
- object: incident.object,
1714
- reason: "",
1715
- count: 1,
1716
- type: "Warning",
1717
- },
1718
- workloads,
1719
- );
1720
- return (
1721
- <HistoryIncidentLine
1722
- key={`${incident.object}-${incident.title}-${idx}`}
1723
- incident={incident}
1724
- action={
1725
- workload ? (
1726
- <button
1727
- type="button"
1728
- onClick={() => onSelectWorkload(workload)}
1729
- className="text-xs font-medium text-accent-text hover:underline"
1730
- >
1731
- Open workload
1732
- </button>
1733
- ) : undefined
1734
- }
2209
+ <div className="w-full max-w-[2400px] p-4 sm:p-6">
2210
+ <section className="overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
2211
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
2212
+ <div className="min-w-0">
2213
+ <div className="flex items-center gap-2">
2214
+ <Clock3 className="h-4 w-4 text-theme-text-secondary" />
2215
+ <h2 className="text-sm font-semibold text-theme-text-primary">
2216
+ Application history
2217
+ </h2>
2218
+ {loading && items.length > 0 && (
2219
+ <RefreshCw
2220
+ className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary"
2221
+ aria-label="Refreshing history"
1735
2222
  />
1736
- );
1737
- })}
2223
+ )}
2224
+ </div>
2225
+ <p className="mt-1 text-xs text-theme-text-tertiary">
2226
+ Deployments, configuration changes, and problems attributed to
2227
+ this application.
2228
+ </p>
1738
2229
  </div>
1739
- </ApplicationPanel>
1740
- )}
1741
- {anchors.length > 0 && (
1742
- <ApplicationPanel
1743
- title={
1744
- <span className="flex items-center justify-between gap-3">
1745
- <span>Deployment history</span>
1746
- {history?.sourceRef && onOpenSource && (
1747
- <button
1748
- type="button"
1749
- onClick={() => onOpenSource(history.sourceRef!)}
1750
- className="text-xs font-medium text-accent-text hover:underline"
2230
+ <div className="flex flex-wrap items-center justify-end gap-2">
2231
+ {range &&
2232
+ rangeOptions &&
2233
+ rangeOptions.length > 1 &&
2234
+ onRangeChange && (
2235
+ <select
2236
+ aria-label="Application history range"
2237
+ value={range}
2238
+ onChange={(event) =>
2239
+ onRangeChange(
2240
+ event.target.value as ApplicationHistoryRange,
2241
+ )
2242
+ }
2243
+ className="h-8 rounded-md border border-theme-border bg-theme-base px-2 text-xs text-theme-text-primary"
1751
2244
  >
1752
- {sourceLinkLabel(history.sourceRef)}
1753
- </button>
2245
+ {rangeOptions.map((option) => (
2246
+ <option key={option.value} value={option.value}>
2247
+ {option.label}
2248
+ </option>
2249
+ ))}
2250
+ </select>
1754
2251
  )}
1755
- </span>
1756
- }
1757
- >
1758
- <div className="divide-y divide-theme-border">
1759
- {anchors.map((anchor, idx) => (
1760
- <HistoryAnchorLine
1761
- key={`${anchor.type}-${anchor.timestamp}-${anchor.revision}-${idx}`}
1762
- anchor={anchor}
1763
- />
2252
+ {onOpenTimeline && (
2253
+ <button
2254
+ type="button"
2255
+ onClick={() => onOpenTimeline(visibleItems[0]?.timestamp)}
2256
+ className="inline-flex h-8 items-center gap-1.5 px-2 text-xs font-medium text-accent-text hover:underline"
2257
+ >
2258
+ Open full Timeline
2259
+ <ExternalLink className="h-3.5 w-3.5" />
2260
+ </button>
2261
+ )}
2262
+ </div>
2263
+ </div>
2264
+
2265
+ {availableCategories.length > 1 && (
2266
+ <div
2267
+ className="flex flex-wrap gap-1 border-b border-theme-border px-4 py-2"
2268
+ role="group"
2269
+ aria-label="Application history categories"
2270
+ >
2271
+ {(["all", "deployment", "change", "problem"] as const).map(
2272
+ (value) => {
2273
+ if (value !== "all" && !availableCategories.includes(value))
2274
+ return null;
2275
+ return (
2276
+ <button
2277
+ key={value}
2278
+ type="button"
2279
+ aria-pressed={category === value}
2280
+ onClick={() => setCategory(value)}
2281
+ className={clsx(
2282
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
2283
+ category === value
2284
+ ? "bg-accent-subtle text-accent-text"
2285
+ : "text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary",
2286
+ )}
2287
+ >
2288
+ {applicationHistoryCategoryLabel(value)}
2289
+ </button>
2290
+ );
2291
+ },
2292
+ )}
2293
+ </div>
2294
+ )}
2295
+
2296
+ {(runtimeError || (history?.partialSources?.length ?? 0) > 0) && (
2297
+ <div
2298
+ className={clsx(
2299
+ "border-b border-theme-border bg-theme-elevated px-4 py-2 text-xs",
2300
+ HEALTH_META.degraded.text,
2301
+ )}
2302
+ >
2303
+ {runtimeError && (
2304
+ <div>
2305
+ {hasRuntimeItems
2306
+ ? "Application changes could not be refreshed. Previously loaded changes may be stale."
2307
+ : "Application changes could not be loaded."}
2308
+ {!hasRuntimeItems &&
2309
+ items.some((item) => item.category === "deployment") &&
2310
+ " Deployment-source history is still shown."}
2311
+ </div>
2312
+ )}
2313
+ {history?.partialSources?.map((message, index) => (
2314
+ <div key={`${message}-${index}`}>{message}</div>
1764
2315
  ))}
1765
2316
  </div>
1766
- </ApplicationPanel>
1767
- )}
1768
- {empty && (
1769
- <ApplicationPanel title="History">
1770
- <EmptyState
1771
- variant="inline"
1772
- headline="No retained deployment history."
1773
- body="Current application state is still available in Overview."
1774
- />
1775
- </ApplicationPanel>
1776
- )}
2317
+ )}
2318
+
2319
+ {loading && items.length === 0 && (
2320
+ <div className="flex items-center gap-2 px-4 py-8 text-sm text-theme-text-tertiary">
2321
+ <RefreshCw className="h-4 w-4 animate-spin" />
2322
+ Loading application history...
2323
+ </div>
2324
+ )}
2325
+
2326
+ {groups.map(([label, dateItems]) => (
2327
+ <div key={label}>
2328
+ <div className="border-b border-theme-border bg-theme-base px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2329
+ {label}
2330
+ </div>
2331
+ <div className="divide-y divide-theme-border">
2332
+ {dateItems.map((item) => (
2333
+ <ApplicationHistoryLine
2334
+ key={item.id}
2335
+ item={item}
2336
+ workloads={workloads}
2337
+ onSelectWorkload={onSelectWorkload}
2338
+ onNavigateToResource={onNavigateToResource}
2339
+ onOpenSource={onOpenSource}
2340
+ />
2341
+ ))}
2342
+ </div>
2343
+ </div>
2344
+ ))}
2345
+
2346
+ {empty && (
2347
+ <div className="px-4 py-8">
2348
+ <EmptyState
2349
+ variant="inline"
2350
+ headline="No application history in this range."
2351
+ body="Overview still shows the current application state."
2352
+ />
2353
+ </div>
2354
+ )}
2355
+
2356
+ <div className="space-y-1 border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">
2357
+ {mode === "local" && (
2358
+ <div>
2359
+ Changes and problems reflect events retained by this Radar
2360
+ instance. Deployment-source history may cover a different
2361
+ period.
2362
+ </div>
2363
+ )}
2364
+ {mode === "retained" && (coverageRecordCount ?? 0) > 0 && (
2365
+ <div>
2366
+ Recording coverage is incomplete for part of this range.
2367
+ </div>
2368
+ )}
2369
+ {mode === "retained" && runtimeLimited && (
2370
+ <div>
2371
+ Showing the 10,000 most recent events in these namespaces. Older
2372
+ application activity in this range may not be included.
2373
+ </div>
2374
+ )}
2375
+ </div>
2376
+ </section>
1777
2377
  </div>
1778
2378
  </div>
1779
2379
  );
1780
2380
  }
1781
2381
 
1782
- function HistoryAnchorLine({
1783
- anchor,
2382
+ function ApplicationHistoryLine({
2383
+ item,
2384
+ workloads,
2385
+ onSelectWorkload,
2386
+ onNavigateToResource,
2387
+ onOpenSource,
1784
2388
  }: {
1785
- anchor: NonNullable<AppHistory["anchors"]>[number];
2389
+ item: ApplicationHistoryItem;
2390
+ workloads: AppWorkload[];
2391
+ onSelectWorkload: (workload: AppWorkload) => void;
2392
+ onNavigateToResource?: (resource: ResourceRef) => void;
2393
+ onOpenSource?: (source: AppSourceRef) => void;
1786
2394
  }) {
2395
+ const workload = item.resource
2396
+ ? workloads.find(
2397
+ (candidate) =>
2398
+ candidate.kind.toLowerCase() === item.resource!.kind.toLowerCase() &&
2399
+ candidate.namespace === item.resource!.namespace &&
2400
+ candidate.name === item.resource!.name,
2401
+ )
2402
+ : undefined;
2403
+ const Icon =
2404
+ item.category === "deployment"
2405
+ ? GitCommit
2406
+ : item.category === "problem"
2407
+ ? AlertTriangle
2408
+ : item.resource
2409
+ ? getTopologyIcon(item.resource.kind)
2410
+ : Activity;
2411
+ const action =
2412
+ item.sourceRef && onOpenSource
2413
+ ? {
2414
+ label: sourceLinkLabel(item.sourceRef),
2415
+ run: () => onOpenSource(item.sourceRef!),
2416
+ }
2417
+ : workload
2418
+ ? { label: "Open workload", run: () => onSelectWorkload(workload) }
2419
+ : item.resource && onNavigateToResource
2420
+ ? {
2421
+ label: "Open resource",
2422
+ run: () => onNavigateToResource(item.resource!),
2423
+ }
2424
+ : null;
2425
+ const revisionAlreadyInTitle = Boolean(
2426
+ item.revision &&
2427
+ item.title.toLowerCase().startsWith("helm revision ") &&
2428
+ item.title.endsWith(item.revision),
2429
+ );
2430
+
1787
2431
  return (
1788
- <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
2432
+ <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-3 px-4 py-3">
2433
+ <div
2434
+ className={clsx(
2435
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-md border",
2436
+ item.category === "problem"
2437
+ ? clsx(
2438
+ "border-theme-border bg-theme-elevated",
2439
+ HEALTH_META.degraded.text,
2440
+ )
2441
+ : "border-theme-border bg-theme-elevated text-theme-text-secondary",
2442
+ )}
2443
+ >
2444
+ <Icon className="h-4 w-4" />
2445
+ </div>
1789
2446
  <div className="min-w-0">
1790
2447
  <div className="flex flex-wrap items-center gap-2">
1791
2448
  <span className="font-medium text-theme-text-primary">
1792
- {anchor.title}
2449
+ {item.title}
1793
2450
  </span>
1794
- {anchor.status && (
1795
- <span className={`${CHIP} ${historyStatusTone(anchor.status)}`}>
1796
- {anchor.status}
2451
+ {item.status && (
2452
+ <span className={`${CHIP} ${historyStatusTone(item.status)}`}>
2453
+ {item.status}
1797
2454
  </span>
1798
2455
  )}
2456
+ {item.count && item.count > 1 && (
2457
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>{item.count}x</span>
2458
+ )}
1799
2459
  </div>
1800
- {anchor.revision && (
2460
+ {item.revision && !revisionAlreadyInTitle && (
1801
2461
  <div className="truncate font-mono text-sm text-theme-text-secondary">
1802
- {anchor.revision}
2462
+ {item.revision}
1803
2463
  </div>
1804
2464
  )}
1805
- {anchor.message && (
2465
+ {item.detail && (
1806
2466
  <div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
1807
- {anchor.message}
2467
+ {item.detail}
1808
2468
  </div>
1809
2469
  )}
1810
- {anchor.source && (
1811
- <div className="mt-1 truncate text-xs text-theme-text-tertiary">
1812
- {anchor.source}
1813
- </div>
2470
+ {item.resource && (
2471
+ <button
2472
+ type="button"
2473
+ onClick={action?.run}
2474
+ disabled={!action}
2475
+ className={clsx(
2476
+ "mt-1 truncate text-xs",
2477
+ action
2478
+ ? "text-accent-text hover:underline"
2479
+ : "text-theme-text-tertiary",
2480
+ )}
2481
+ >
2482
+ {getKindLabel(item.resource.kind)} · {item.resource.namespace}/
2483
+ {item.resource.name}
2484
+ </button>
1814
2485
  )}
1815
2486
  </div>
1816
2487
  <div className="text-right text-xs text-theme-text-tertiary">
1817
- {formatAppEventTime(anchor.timestamp)}
1818
- {anchor.initiatedBy && <div>{anchor.initiatedBy}</div>}
1819
- </div>
1820
- </div>
1821
- );
1822
- }
1823
-
1824
- function HistoryIncidentLine({
1825
- incident,
1826
- action,
1827
- }: {
1828
- incident: NonNullable<AppHistory["incidents"]>[number];
1829
- action?: ReactNode;
1830
- }) {
1831
- return (
1832
- <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
1833
- <div className="min-w-0">
1834
- <div className="truncate font-medium text-theme-text-primary">
1835
- {incident.title}
1836
- </div>
1837
- {incident.object && (
1838
- <div className="truncate text-sm text-theme-text-secondary">
1839
- {incident.object}
1840
- </div>
2488
+ <div>{applicationHistoryTimeLabel(item.timestamp)}</div>
2489
+ {item.initiatedBy && (
2490
+ <div className="mt-1 max-w-40 truncate">{item.initiatedBy}</div>
1841
2491
  )}
1842
- {incident.message && (
1843
- <div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
1844
- {incident.message}
1845
- </div>
2492
+ {action && item.sourceRef && (
2493
+ <button
2494
+ type="button"
2495
+ onClick={action.run}
2496
+ className="mt-1 font-medium text-accent-text hover:underline"
2497
+ >
2498
+ {action.label}
2499
+ </button>
1846
2500
  )}
1847
2501
  </div>
1848
- <div className="text-right text-xs text-theme-text-tertiary">
1849
- {formatAppEventTime(incident.lastSeen)}
1850
- {incident.count && incident.count > 1 && <div>{incident.count}x</div>}
1851
- {action && <div className="mt-1">{action}</div>}
1852
- </div>
1853
2502
  </div>
1854
2503
  );
1855
2504
  }
1856
2505
 
2506
+ function applicationHistoryCategoryLabel(
2507
+ category: ApplicationHistoryCategory | "all",
2508
+ ): string {
2509
+ if (category === "all") return "All";
2510
+ if (category === "deployment") return "Deployments";
2511
+ if (category === "problem") return "Problems";
2512
+ return "Changes";
2513
+ }
2514
+
2515
+ function applicationHistoryDateLabel(timestamp: string): string {
2516
+ const date = new Date(timestamp);
2517
+ const today = new Date();
2518
+ const yesterday = new Date(today);
2519
+ yesterday.setDate(today.getDate() - 1);
2520
+ if (date.toDateString() === today.toDateString()) return "Today";
2521
+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
2522
+ return new Intl.DateTimeFormat(undefined, {
2523
+ month: "short",
2524
+ day: "numeric",
2525
+ year: date.getFullYear() === today.getFullYear() ? undefined : "numeric",
2526
+ }).format(date);
2527
+ }
2528
+
2529
+ function applicationHistoryTimeLabel(timestamp: string): string {
2530
+ return new Intl.DateTimeFormat(undefined, {
2531
+ hour: "2-digit",
2532
+ minute: "2-digit",
2533
+ }).format(new Date(timestamp));
2534
+ }
2535
+
1857
2536
  function buildAppIssues(
1858
2537
  workloads: AppWorkload[],
1859
2538
  events: NonNullable<AppRow["events"]>,
@@ -1968,41 +2647,61 @@ function historyStatusTone(status: string): string {
1968
2647
  }
1969
2648
 
1970
2649
  function sourceLinkLabel(source: AppSourceRef): string {
1971
- return `View ${sourceObjectLabel(source)}`
2650
+ return `View ${sourceObjectLabel(source)}`;
1972
2651
  }
1973
2652
 
1974
2653
  function sourceObjectLabel(source: AppSourceRef): string {
1975
- if (source.type === 'helm') return 'Helm release'
1976
- if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD application'
1977
- if (source.kind.toLowerCase() === 'helmrelease') return 'Flux HelmRelease'
1978
- if (source.kind.toLowerCase() === 'kustomization') return 'Flux Kustomization'
1979
- return 'GitOps source'
2654
+ if (source.type === "helm") return "Helm release";
2655
+ if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
2656
+ return "Argo CD application";
2657
+ if (source.kind.toLowerCase() === "helmrelease") return "Flux HelmRelease";
2658
+ if (source.kind.toLowerCase() === "kustomization")
2659
+ return "Flux Kustomization";
2660
+ return "GitOps source";
1980
2661
  }
1981
2662
 
1982
2663
  function sourceInventoryLabel(source: AppSourceRef): string {
1983
- if (source.type === 'helm') return 'Helm'
1984
- if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD'
1985
- if (source.tool === 'fluxcd') return 'Flux'
1986
- return 'GitOps'
2664
+ if (source.type === "helm") return "Helm";
2665
+ if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
2666
+ return "Argo CD";
2667
+ if (source.tool === "fluxcd") return "Flux";
2668
+ return "GitOps";
1987
2669
  }
1988
2670
 
1989
- function SourceDeliveryStatus({ status }: { status: NonNullable<AppRow['sourceStatus']> }) {
2671
+ function SourceDeliveryStatus({
2672
+ status,
2673
+ }: {
2674
+ status: NonNullable<AppRow["sourceStatus"]>;
2675
+ }) {
1990
2676
  const values = [
1991
- status.sync ? { label: status.sync, tone: sourceSyncHealth(status.sync) } : null,
1992
- status.health ? { label: status.health, tone: sourceReportedHealth(status.health) } : null,
1993
- ].filter((value): value is { label: string; tone: AppHealth } => value !== null)
2677
+ status.sync
2678
+ ? { label: status.sync, tone: sourceSyncHealth(status.sync) }
2679
+ : null,
2680
+ status.health
2681
+ ? { label: status.health, tone: sourceReportedHealth(status.health) }
2682
+ : null,
2683
+ ].filter(
2684
+ (value): value is { label: string; tone: AppHealth } => value !== null,
2685
+ );
1994
2686
 
1995
2687
  return (
1996
2688
  <span className="inline-flex min-w-0 items-center gap-2">
1997
2689
  {values.map((value, index) => (
1998
- <span key={`${value.label}-${index}`} className="inline-flex min-w-0 items-center gap-1.5">
1999
- {index > 0 && <span className="text-theme-text-tertiary" aria-hidden>·</span>}
2690
+ <span
2691
+ key={`${value.label}-${index}`}
2692
+ className="inline-flex min-w-0 items-center gap-1.5"
2693
+ >
2694
+ {index > 0 && (
2695
+ <span className="text-theme-text-tertiary" aria-hidden>
2696
+ ·
2697
+ </span>
2698
+ )}
2000
2699
  <StatusDot tone={mapHealthToTone(value.tone)} />
2001
2700
  <span className="truncate">{value.label}</span>
2002
2701
  </span>
2003
2702
  ))}
2004
2703
  </span>
2005
- )
2704
+ );
2006
2705
  }
2007
2706
 
2008
2707
  function formatAppEventTime(value?: string): string {
@@ -2035,12 +2734,12 @@ function ApplicationFact({
2035
2734
  monoDetail,
2036
2735
  variant = "card",
2037
2736
  }: {
2038
- label: string
2039
- value: ReactNode
2040
- detail?: string
2041
- monoValue?: boolean
2042
- monoDetail?: boolean
2043
- variant?: 'card' | 'row' | 'bare'
2737
+ label: string;
2738
+ value: ReactNode;
2739
+ detail?: string;
2740
+ monoValue?: boolean;
2741
+ monoDetail?: boolean;
2742
+ variant?: "card" | "row" | "bare";
2044
2743
  }) {
2045
2744
  if (variant === "row") {
2046
2745
  return (
@@ -2287,20 +2986,23 @@ function TopologyWorkloadLegend({
2287
2986
  deploymentSource,
2288
2987
  onOpenSource,
2289
2988
  }: {
2290
- workloads: AppWorkload[]
2291
- colorByWorkload: Map<string, number> | null
2292
- focusedOwnerId: WorkloadFocus
2293
- showSharedOrUnscoped: boolean
2294
- onFocus: (owner: WorkloadFocus) => void
2295
- onSelectWorkload: (workload: AppWorkload) => void
2296
- deploymentLayer: DeploymentTopologyLayer | null
2297
- deploymentSource?: AppSourceRef
2298
- onOpenSource?: (source: AppSourceRef) => void
2989
+ workloads: AppWorkload[];
2990
+ colorByWorkload: Map<string, number> | null;
2991
+ focusedOwnerId: WorkloadFocus;
2992
+ showSharedOrUnscoped: boolean;
2993
+ onFocus: (owner: WorkloadFocus) => void;
2994
+ onSelectWorkload: (workload: AppWorkload) => void;
2995
+ deploymentLayer: DeploymentTopologyLayer | null;
2996
+ deploymentSource?: AppSourceRef;
2997
+ onOpenSource?: (source: AppSourceRef) => void;
2299
2998
  }) {
2300
- const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0
2301
- const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0
2302
- const showSourceDifferences = !!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0
2303
- const sourceLabel = deploymentSource ? sourceInventoryLabel(deploymentSource) : ''
2999
+ const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0;
3000
+ const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0;
3001
+ const showSourceDifferences =
3002
+ !!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0;
3003
+ const sourceLabel = deploymentSource
3004
+ ? sourceInventoryLabel(deploymentSource)
3005
+ : "";
2304
3006
 
2305
3007
  return (
2306
3008
  <div className="absolute left-4 top-4 z-10 w-[min(18.5rem,calc(100%-2rem))] 2xl:w-[min(22rem,calc(100%-2rem))] overflow-hidden rounded-lg border border-theme-border bg-theme-surface/95 shadow-theme-md backdrop-blur">
@@ -2347,20 +3049,30 @@ function TopologyWorkloadLegend({
2347
3049
  </div>
2348
3050
  {showSourceDifferences && deploymentSource && (
2349
3051
  <div className="border-t border-theme-border px-3 py-2.5">
2350
- <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Source differences</div>
3052
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
3053
+ Source differences
3054
+ </div>
2351
3055
  <div className="space-y-1">
2352
3056
  {managedOnlyCount > 0 && (
2353
3057
  <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
2354
3058
  <DeploymentSourceLogo source={deploymentSource} />
2355
3059
  <span className="min-w-0 flex-1">
2356
- <span className="block">{managedOnlyCount} only in {sourceLabel}</span>
3060
+ <span className="block">
3061
+ {managedOnlyCount} only in {sourceLabel}
3062
+ </span>
2357
3063
  {deploymentLayer?.managedOnlySummary && (
2358
- <span className="mt-0.5 block text-[10px] text-theme-text-tertiary">{deploymentLayer.managedOnlySummary}</span>
3064
+ <span className="mt-0.5 block text-[10px] text-theme-text-tertiary">
3065
+ {deploymentLayer.managedOnlySummary}
3066
+ </span>
2359
3067
  )}
2360
3068
  </span>
2361
3069
  {onOpenSource && (
2362
3070
  <Tooltip content={`Open ${sourceLabel} view`} delay={150}>
2363
- <button type="button" onClick={() => onOpenSource(deploymentSource)} className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary">
3071
+ <button
3072
+ type="button"
3073
+ onClick={() => onOpenSource(deploymentSource)}
3074
+ className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
3075
+ >
2364
3076
  <ExternalLink className="h-3 w-3" aria-hidden />
2365
3077
  </button>
2366
3078
  </Tooltip>
@@ -2369,8 +3081,13 @@ function TopologyWorkloadLegend({
2369
3081
  )}
2370
3082
  {runtimeOnlyCount > 0 && (
2371
3083
  <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
2372
- <Radio className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
2373
- <span>{pluralize(runtimeOnlyCount, 'runtime-only resource')}</span>
3084
+ <Radio
3085
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
3086
+ aria-hidden
3087
+ />
3088
+ <span>
3089
+ {pluralize(runtimeOnlyCount, "runtime-only resource")}
3090
+ </span>
2374
3091
  </div>
2375
3092
  )}
2376
3093
  </div>
@@ -2381,10 +3098,21 @@ function TopologyWorkloadLegend({
2381
3098
  }
2382
3099
 
2383
3100
  function DeploymentSourceLogo({ source }: { source: AppSourceRef }) {
2384
- const sourceLabel = sourceInventoryLabel(source)
2385
- const logo = sourceLabel === 'Argo CD' ? argoCdLogo : sourceLabel === 'Flux' ? fluxLogo : null
2386
- if (!logo) return <Layers className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
2387
- return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />
3101
+ const sourceLabel = sourceInventoryLabel(source);
3102
+ const logo =
3103
+ sourceLabel === "Argo CD"
3104
+ ? argoCdLogo
3105
+ : sourceLabel === "Flux"
3106
+ ? fluxLogo
3107
+ : null;
3108
+ if (!logo)
3109
+ return (
3110
+ <Layers
3111
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
3112
+ aria-hidden
3113
+ />
3114
+ );
3115
+ return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />;
2388
3116
  }
2389
3117
 
2390
3118
  function StaticWorkloadScope({ workload }: { workload: AppWorkload }) {