@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -13,9 +13,11 @@ export interface ApplicationsListProps {
13
13
  onSelect: (key: string) => void
14
14
  /** Leading element in the header actions (e.g. a freshness control). */
15
15
  headerActions?: ReactNode
16
+ /** First fetch in flight — chassis renders its shape-stable skeleton. */
17
+ loading?: boolean
16
18
  }
17
19
 
18
- export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
20
+ export function ApplicationsList({ apps, onSelect, headerActions, loading }: ApplicationsListProps) {
19
21
  // Env tokens this CLUSTER proved (identity classifications on the wire) feed
20
22
  // the namespace heuristic, so sibling-less rows in discovered env namespaces
21
23
  // still label without any hardcoded vocabulary.
@@ -24,6 +26,7 @@ export function ApplicationsList({ apps, onSelect, headerActions }: Applications
24
26
 
25
27
  return (
26
28
  <ApplicationsView
29
+ loading={loading}
27
30
  variant="single"
28
31
  entries={entries}
29
32
  onSelect={onSelect}
@@ -5,6 +5,7 @@ import { clsx } from 'clsx'
5
5
  import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { EmptyState } from '../ui/EmptyState'
8
+ import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
8
9
  import { SearchBox } from '../ui/SearchBox'
9
10
  import { useFilterState, defineFilterSchema } from '../../filter-state'
10
11
  import { PageHeader } from '../ui/PageHeader'
@@ -16,6 +17,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
16
17
  import { pluralize } from '../../utils/pluralize'
17
18
  import {
18
19
  type AppEntry,
20
+ type AppRow,
19
21
  type AppHealth,
20
22
  type AppWorkloadClass,
21
23
  type AppSource,
@@ -35,12 +37,34 @@ import {
35
37
  isSystemNamespace,
36
38
  searchTextForEntry,
37
39
  foldAppGroups,
40
+ batchSignalForApp,
41
+ batchRuntimeForApp,
38
42
  type FoldedRow,
39
43
  } from '../../utils/applications'
40
44
  import { ReadyBar } from './ReadyBar'
41
- import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
45
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
42
46
  import { AppIdentityTooltip, EnvHint } from './AppTooltips'
43
47
 
48
+ function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
49
+ if (workloadClass !== 'job') return <ReadyBar ready={ready} desired={desired} />
50
+ const runtimes = apps.map(batchRuntimeForApp)
51
+ const worst = runtimes.reduce((current, runtime) => (HEALTH_RANK[runtime.health] ?? 0) > (HEALTH_RANK[current.health] ?? 0) ? runtime : current, runtimes[0])
52
+ if (!worst) return <span className="text-xs text-theme-text-tertiary">Idle</span>
53
+ const same = runtimes.every((runtime) => runtime.label === worst.label)
54
+ const label = same ? worst.label : `${runtimes.length} states`
55
+ return (
56
+ <Tooltip content={runtimes.map((runtime, index) => `${apps[index]?.name ?? 'app'}: ${runtime.label} — ${runtime.detail}`).join('\n')} delay={150}>
57
+ <span className="inline-flex items-center gap-1.5 text-xs text-theme-text-secondary"><StatusDot tone={mapHealthToTone(worst.health)} />{label}</span>
58
+ </Tooltip>
59
+ )
60
+ }
61
+
62
+ function applicationRuntimeLabel(apps: AppRow[], workloadClass: AppWorkloadClass, health: AppHealth): string {
63
+ if (workloadClass !== 'job') return HEALTH_META[health].label
64
+ const labels = Array.from(new Set(apps.map((app) => batchRuntimeForApp(app).label)))
65
+ return labels.length === 1 ? labels[0] : 'Multiple batch states'
66
+ }
67
+
44
68
  // ApplicationsView — the shared, variant-agnostic core behind the Applications
45
69
  // surface. It owns the entire list chassis: a health hero header (PageHeader +
46
70
  // SummaryTiles + DistributionBar), a left facet rail (Availability / Class /
@@ -51,7 +75,7 @@ import { AppIdentityTooltip, EnvHint } from './AppTooltips'
51
75
  // the per-instance row. Styling mirrors the Resources table so the surfaces
52
76
  // read as one design.
53
77
 
54
- // Availability is the one status facet — map app health onto the shared facet
78
+ // Status is the one health facet — map app health onto the shared facet
55
79
  // tone so its dots read red/amber/green/grey like the GitOps sync+health facets.
56
80
  const HEALTH_TONE: Record<AppHealth, FacetTone> = {
57
81
  unhealthy: 'error',
@@ -71,7 +95,7 @@ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
71
95
  case 'name':
72
96
  return a.row.name.localeCompare(b.row.name)
73
97
  case 'ready':
74
- return a.readyRatio - b.readyRatio
98
+ return runtimeSortValue(a) - runtimeSortValue(b)
75
99
  case 'version': {
76
100
  // Sort by distinct-version count first (skewed apps cluster), then the
77
101
  // first tag for a stable, human-meaningful order.
@@ -82,6 +106,10 @@ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
82
106
  }
83
107
  }
84
108
 
109
+ function runtimeSortValue(entry: AppEntry): number {
110
+ return entry.workloadClass === 'job' ? (HEALTH_RANK[entry.health] ?? 0) : entry.readyRatio
111
+ }
112
+
85
113
  // The env token an entry filters under. Single entries carry one env; fleet
86
114
  // entries carry several — a fleet row matches an env facet if ANY of its slices
87
115
  // do (the same inclusive policy as the Class facet).
@@ -100,6 +128,9 @@ export interface ApplicationsViewProps {
100
128
  emptySlot?: ReactNode
101
129
  /** Leading element in the header actions cluster (e.g. a freshness control). */
102
130
  headerActions?: ReactNode
131
+ /** First fetch in flight — render the shape-stable skeleton (pulsing tiles,
132
+ * rail stubs, table rows) instead of collapsing the chassis. */
133
+ loading?: boolean
103
134
  }
104
135
 
105
136
  const APPS_FILTER_SCHEMA = defineFilterSchema({
@@ -112,7 +143,7 @@ const APPS_FILTER_SCHEMA = defineFilterSchema({
112
143
  system: { param: 'system', type: 'boolean' },
113
144
  })
114
145
 
115
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
146
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions, loading }: ApplicationsViewProps) {
116
147
  // Facets + search + show-system live in the URL (shareable, bookmarkable) via
117
148
  // the shared filter-state contract. Sort stays local: it's a compound
118
149
  // {key, dir} view-preference, not a result-narrowing filter, so it isn't
@@ -274,9 +305,16 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
274
305
  .sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
275
306
  .map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
276
307
 
308
+ // First fetch, nothing to show yet: drive the shape-stable skeleton. The
309
+ // loaded state hides zero-count health tiles, so without this the header
310
+ // (tiles + distribution bar), rail, and table would all pop in at once.
311
+ const initialLoading = Boolean(loading) && allEntries.length === 0
312
+
277
313
  // Clickable status tile wired to the health facet — tap to filter to that tier.
278
314
  const healthTile = (h: AppHealth, tone: SummaryTone) =>
279
- counts.health[h] ? (
315
+ initialLoading && (h === 'healthy' || h === 'degraded' || h === 'unhealthy') ? (
316
+ <SummaryTile key={h} label={HEALTH_META[h].label} value={0} tone={tone} loading />
317
+ ) : counts.health[h] ? (
280
318
  <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => filters.toggle('health', h)} />
281
319
  ) : null
282
320
 
@@ -301,7 +339,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
301
339
  actions={
302
340
  <>
303
341
  {headerActions}
304
- <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
342
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
305
343
  {healthTile('unhealthy', 'error')}
306
344
  {healthTile('degraded', 'warning')}
307
345
  {healthTile('healthy', 'success')}
@@ -310,11 +348,15 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
310
348
  </>
311
349
  }
312
350
  />
313
- <DistributionBar
314
- className="mt-3"
315
- ariaLabel="Health distribution"
316
- segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
317
- />
351
+ {initialLoading ? (
352
+ <div className="mt-3 h-1.5 w-full animate-pulse rounded-full bg-theme-hover" aria-hidden />
353
+ ) : (
354
+ <DistributionBar
355
+ className="mt-3"
356
+ ariaLabel="Health distribution"
357
+ segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
358
+ />
359
+ )}
318
360
  </div>
319
361
 
320
362
  {/* Body: filter sidebar | content (toolbar + table). */}
@@ -330,7 +372,19 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
330
372
  )}
331
373
  </div>
332
374
  <div className="flex-1 overflow-y-auto">
333
- <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
375
+ {initialLoading ? (
376
+ <BoardRailSkeleton
377
+ sections={[
378
+ ['Status', 5],
379
+ ['Class', 3],
380
+ ['Type', 4],
381
+ ['Environment', 3],
382
+ ['Source', 3],
383
+ ]}
384
+ />
385
+ ) : (
386
+ <>
387
+ <Facet icon={HeartPulse} title="Status" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
334
388
  <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => filters.toggle('class', v)} />
335
389
  <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => filters.toggle('type', v)} />
336
390
  <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => filters.toggle('env', v)} />
@@ -342,6 +396,8 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
342
396
  <span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
343
397
  </label>
344
398
  )}
399
+ </>
400
+ )}
345
401
  </div>
346
402
  </aside>
347
403
 
@@ -369,7 +425,9 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
369
425
  </div>
370
426
 
371
427
  <div className="min-w-0 flex-1 overflow-auto bg-theme-base">
372
- {entries.length === 0 ? (
428
+ {initialLoading ? (
429
+ <BoardTableSkeleton />
430
+ ) : entries.length === 0 ? (
373
431
  emptySlot && allEntries.length === 0 ? (
374
432
  emptySlot
375
433
  ) : (
@@ -399,7 +457,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
399
457
  </>
400
458
  )}
401
459
  <th className={TH_CLASS}>Class</th>
402
- <SortableTh label="Ready" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
460
+ <SortableTh label="Runtime" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
403
461
  <SortableTh label="Version" sortKey="version" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
404
462
  <th className={TH_CLASS}>Workloads</th>
405
463
  <th className={clsx(TH_CLASS, 'w-8')} />
@@ -421,11 +479,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
421
479
  <span className="flex items-center gap-2">
422
480
  {/* Left status stripe (worst-child health) — the row status
423
481
  gutter shared with the GitOps table. */}
424
- <Tooltip content={HEALTH_META[r.health].label} delay={150}>
482
+ <Tooltip content={applicationRuntimeLabel(r.members.map((member) => member.row), r.workloadClass, r.health)} delay={150}>
425
483
  <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
426
484
  </Tooltip>
427
485
  <ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
428
486
  <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
487
+ {r.workloadClass !== 'job' && <BatchSignalChip signal={firstBatchSignal(r.members.map((m) => m.row))} />}
429
488
  <Tooltip
430
489
  content={<AppIdentityTooltip identityKey={r.label} source={r.members[0]?.row.identity?.source} portable={r.members[0]?.row.identity?.portable} fleet={variant === 'fleet'} members={r.members.map((m) => ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />}
431
490
  delay={150}
@@ -473,7 +532,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
473
532
  </span>
474
533
  </td>
475
534
  <td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
476
- <td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
535
+ <td className="px-2 py-2.5"><ApplicationRuntimeCell apps={r.members.map((member) => member.row)} workloadClass={r.workloadClass} ready={r.ready} desired={r.desired} /></td>
477
536
  <td className="px-2 py-2.5">
478
537
  {r.lag ? (
479
538
  <Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
@@ -500,10 +559,11 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
500
559
  >
501
560
  <td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
502
561
  <span className="flex items-center gap-2">
503
- <Tooltip content={HEALTH_META[e.health].label} delay={150}>
562
+ <Tooltip content={applicationRuntimeLabel([e.row], e.workloadClass, e.health)} delay={150}>
504
563
  <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[e.health].bar)} />
505
564
  </Tooltip>
506
565
  <span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
566
+ {e.workloadClass !== 'job' && <BatchSignalChip signal={batchSignalForApp(e.row)} />}
507
567
  <ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
508
568
  <CategoryChip category={e.category} addonReason={e.row.addonReason} />
509
569
  </span>
@@ -570,7 +630,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
570
630
  </>
571
631
  )}
572
632
  <td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
573
- <td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
633
+ <td className="px-2 py-2.5"><ApplicationRuntimeCell apps={[e.row]} workloadClass={e.workloadClass} ready={e.ready} desired={e.desired} /></td>
574
634
  <td className="px-2 py-2.5">
575
635
  {e.variant === 'fleet' ? (
576
636
  e.versionSkew ? (
@@ -609,3 +669,26 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
609
669
  </div>
610
670
  )
611
671
  }
672
+
673
+ function firstBatchSignal(rows: AppRow[]) {
674
+ let best = null as ReturnType<typeof batchSignalForApp>
675
+ const rank = (tone: string | undefined) => {
676
+ switch (tone) {
677
+ case 'rose':
678
+ return 5
679
+ case 'amber':
680
+ return 4
681
+ case 'sky':
682
+ return 3
683
+ case 'emerald':
684
+ return 2
685
+ default:
686
+ return 0
687
+ }
688
+ }
689
+ for (const row of rows) {
690
+ const signal = batchSignalForApp(row)
691
+ if (signal && rank(signal.tone) > rank(best?.tone)) best = signal
692
+ }
693
+ return best
694
+ }
@@ -67,6 +67,53 @@ describe('buildSingleAppEntry', () => {
67
67
  expect(e.env).toBe('staging')
68
68
  expect(e.envInferred).toBe(false)
69
69
  })
70
+
71
+ it('uses delivery degradation in the displayed application health', () => {
72
+ const e = buildSingleAppEntry(app({
73
+ health: 'degraded',
74
+ runtimeHealth: 'healthy',
75
+ sourceStatus: { sync: 'Synced', health: 'Degraded' },
76
+ }))
77
+
78
+ expect(e.health).toBe('degraded')
79
+ })
80
+
81
+ it('preserves the latest retained batch outcome when delivery adds no degradation', () => {
82
+ const e = buildSingleAppEntry(app({
83
+ health: 'unhealthy',
84
+ runtimeHealth: 'unhealthy',
85
+ workload_class: 'job',
86
+ workloads: [wl({
87
+ kind: 'CronJob',
88
+ workload_class: 'job',
89
+ health: 'unhealthy',
90
+ ready: 0,
91
+ desired: 0,
92
+ batch: { retainedRuns: 2, failedRuns: 1, succeededRuns: 1, latestRunPhase: 'Succeeded' },
93
+ })],
94
+ }))
95
+
96
+ expect(e.health).toBe('healthy')
97
+ })
98
+
99
+ it('applies delivery degradation to a batch app with a successful latest run', () => {
100
+ const e = buildSingleAppEntry(app({
101
+ health: 'unhealthy',
102
+ runtimeHealth: 'unhealthy',
103
+ workload_class: 'job',
104
+ sourceStatus: { sync: 'Synced', health: 'Degraded' },
105
+ workloads: [wl({
106
+ kind: 'CronJob',
107
+ workload_class: 'job',
108
+ health: 'unhealthy',
109
+ ready: 0,
110
+ desired: 0,
111
+ batch: { retainedRuns: 2, failedRuns: 1, succeededRuns: 1, latestRunPhase: 'Succeeded' },
112
+ })],
113
+ }))
114
+
115
+ expect(e.health).toBe('degraded')
116
+ })
70
117
  })
71
118
 
72
119
  describe('searchTextForEntry (single)', () => {
@@ -7,7 +7,7 @@ export { buildSingleAppEntry } from '../../utils/applications'
7
7
  // Facet moved to the shared ui/ primitives; re-exported here for compatibility.
8
8
  export { Facet } from '../ui/Facet'
9
9
  export { ApplicationDetail } from './ApplicationDetail'
10
- export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance } from './ApplicationDetail'
10
+ export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance, ApplicationView } from './ApplicationDetail'
11
11
  export { CenteredEmpty } from '../ui/CenteredEmpty'
12
12
  export { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
13
13
  export { ProvenanceTooltip, CategoryTooltip, VersionTooltip, AppIdentityTooltip } from './AppTooltips'
@@ -41,14 +41,15 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
41
41
  const warnings = findings.filter(f => f.severity === 'warning').length
42
42
 
43
43
  return (
44
- <div className="border-b-subtle pb-4 last:border-0">
44
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
45
45
  <button
46
+ type="button"
46
47
  onClick={() => setExpanded(!expanded)}
47
- className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
48
+ className="flex w-full items-center gap-2 rounded-md text-left transition-colors hover:text-theme-text-primary"
48
49
  >
49
50
  <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
50
51
  <ClipboardCheck className="w-4 h-4 text-theme-text-secondary" />
51
- <span className="text-sm font-medium text-theme-text-secondary">Audit Findings</span>
52
+ <span className="text-sm font-semibold text-theme-text-primary">Audit Findings</span>
52
53
  <div className="flex items-center gap-2 ml-1">
53
54
  {dangers > 0 && (
54
55
  <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT.error)}>{dangers} critical</span>
@@ -64,7 +65,7 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
64
65
  style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
65
66
  >
66
67
  <div className="overflow-hidden">
67
- <div className="pl-6 pt-1 flex flex-col gap-0.5">
68
+ <div className="flex flex-col gap-0.5 pt-3">
68
69
  {findings.map((f, i) => {
69
70
  const isDanger = f.severity === 'danger'
70
71
  return (
@@ -74,17 +75,20 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
74
75
  ) : (
75
76
  <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT.warning)} />
76
77
  )}
77
- <span className="text-xs text-theme-text-secondary flex-1">{f.message}</span>
78
- <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
79
- {f.category}
80
- </span>
78
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
79
+ <span className="text-xs text-theme-text-secondary">{f.message}</span>
80
+ <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
81
+ {f.category}
82
+ </span>
83
+ </div>
81
84
  </div>
82
85
  )
83
86
  })}
84
87
  {onViewAll && (
85
88
  <button
89
+ type="button"
86
90
  onClick={(e) => { e.stopPropagation(); onViewAll() }}
87
- className="flex items-center gap-1 text-xs text-skyhook-500 hover:text-skyhook-400 mt-1 py-1 transition-colors"
91
+ className="mt-1 flex items-center gap-1 py-1 text-xs text-accent-text transition-colors hover:text-accent"
88
92
  >
89
93
  View all findings
90
94
  <ArrowRight className="w-3 h-3" />
@@ -93,6 +97,6 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
93
97
  </div>
94
98
  </div>
95
99
  </div>
96
- </div>
100
+ </section>
97
101
  )
98
102
  }