@skyhook-io/k8s-ui 1.16.2 → 1.16.3

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 (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  3. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  4. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  5. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  6. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  7. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  8. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  9. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  10. package/src/components/cnpg/index.ts +14 -0
  11. package/src/components/cnpg/primitives.tsx +134 -0
  12. package/src/components/cnpg/relations.test.ts +304 -0
  13. package/src/components/cnpg/relations.ts +417 -0
  14. package/src/components/cnpg/workspace.test.ts +285 -0
  15. package/src/components/cnpg/workspace.ts +700 -0
  16. package/src/components/issues/IssuesView.tsx +10 -1
  17. package/src/components/issues/types.ts +24 -0
  18. package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
  19. package/src/components/logs/useLogBuffer.test.ts +17 -0
  20. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  21. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  22. package/src/components/resources/ResourcesView.tsx +5 -1
  23. package/src/components/resources/index.ts +1 -1
  24. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  25. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  26. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  27. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  28. package/src/components/resources/status-overclaim.test.ts +1 -1
  29. package/src/components/timeline/TimelineList.tsx +185 -23
  30. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  31. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  32. package/src/components/timeline/index.ts +1 -0
  33. package/src/components/timeline/timeline-filters.ts +7 -0
  34. package/src/components/workload/WorkloadView.tsx +96 -5
  35. package/src/components/workload/index.ts +1 -0
  36. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  37. package/src/index.ts +3 -0
  38. package/src/utils/log-level.ts +14 -0
@@ -77,9 +77,19 @@ import { rolloutMayAdvanceAutomatically, type WorkloadRolloutActivity } from '..
77
77
  import { WorkloadRolloutNotice } from './WorkloadRolloutNotice'
78
78
  import { isCoreBatchJob } from '../../utils/api-resources'
79
79
 
80
- export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
80
+ export type WorkloadTabType = 'overview' | 'spec' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
81
81
  type TabType = WorkloadTabType
82
82
 
83
+ /** A host-provided tab. `after` places it behind a built-in tab; `replaces` hides that built-in tab. */
84
+ export interface WorkloadExtraTab {
85
+ id: string
86
+ label: string
87
+ icon?: ReactNode
88
+ after?: WorkloadTabType
89
+ replaces?: WorkloadTabType
90
+ render: () => ReactNode
91
+ }
92
+
83
93
  export interface ResourceOwnershipContext {
84
94
  application?: {
85
95
  key: string
@@ -286,6 +296,24 @@ interface WorkloadViewProps {
286
296
  initialContainer: string | null
287
297
  onConsumeInitialContainer: () => void
288
298
  }) => ReactNode
299
+ /**
300
+ * A composed summary for kinds that have one. When it returns content, the
301
+ * Overview tab shows the summary and the resource's own renderer moves to a
302
+ * "Spec & status" tab, so the same facts never render twice. Return null to
303
+ * keep the default Overview.
304
+ */
305
+ renderSummary?: (props: {
306
+ kind: string
307
+ apiKind: string
308
+ namespace: string
309
+ name: string
310
+ group?: string
311
+ resource: any
312
+ context: 'drawer' | 'expanded'
313
+ onNavigate?: NavigateToResource
314
+ }) => ReactNode
315
+ /** Extra tabs for the expanded view (e.g. a domain's own sections). */
316
+ extraTabs?: WorkloadExtraTab[]
289
317
  /** Render a full replacement for the expanded Overview tab. */
290
318
  renderExpandedOverview?: (props: {
291
319
  kind: string
@@ -434,6 +462,8 @@ export function WorkloadView({
434
462
  renderDiagnoseTab,
435
463
  reachableVia,
436
464
  renderExpandedOverview,
465
+ renderSummary,
466
+ extraTabs,
437
467
  renderRelatedYaml,
438
468
  renderMetricsTab,
439
469
  renderCostTab,
@@ -480,8 +510,10 @@ export function WorkloadView({
480
510
 
481
511
  // Collapsed mode state (YAML toggle for drawer mode)
482
512
  const [showYaml, setShowYaml] = useState(initialTab === 'yaml')
513
+ const [drawerSpec, setDrawerSpec] = useState(false)
483
514
  useEffect(() => {
484
515
  setShowYaml(initialTab === 'yaml')
516
+ setDrawerSpec(false)
485
517
  }, [kindProp, namespace, name, initialTab])
486
518
 
487
519
  const switchView = useCallback((yaml: boolean) => {
@@ -776,8 +808,12 @@ export function WorkloadView({
776
808
  const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
777
809
  const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading)
778
810
  const requestedTab: TabType = activeTab
811
+ const summaryContext = { kind, apiKind, namespace, name, group, resource, onNavigate: onNavigateToResource }
812
+ const expandedSummary = expanded && resource ? renderSummary?.({ ...summaryContext, context: 'expanded' }) ?? null : null
813
+ const drawerSummary = !expanded && resource ? renderSummary?.({ ...summaryContext, context: 'drawer' }) ?? null : null
779
814
  const tabs: DetailShellTab<TabType>[] = [
780
815
  { id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
816
+ { id: 'spec', label: 'Spec & status', icon: <FileText className="w-4 h-4" />, hidden: !expandedSummary },
781
817
  { id: 'topology', label: 'Topology', icon: <Network className="w-4 h-4" />, hidden: topologyTabHidden },
782
818
  {
783
819
  id: 'timeline',
@@ -796,12 +832,15 @@ export function WorkloadView({
796
832
  { id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
797
833
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
798
834
  ]
799
- const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
835
+ const allTabs = mergeExtraTabs(tabs, expanded ? extraTabs : undefined)
836
+ const requestedTabAvailable = allTabs.some((tab) => tab.id === requestedTab && !tab.hidden)
800
837
  const effectiveTab: TabType = requestedTabAvailable ? requestedTab : 'overview'
838
+ const activeExtraTab = expanded ? extraTabs?.find((x) => x.id === effectiveTab) : undefined
801
839
  const shouldCommitFallback =
802
840
  requestedTab !== 'overview' &&
803
841
  !requestedTabAvailable &&
804
842
  (
843
+ (requestedTab === 'spec' && !!resource && !resourceLoading && !expandedSummary) ||
805
844
  (requestedTab === 'topology' && topologyTabHidden) ||
806
845
  (requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) ||
807
846
  (requestedTab === 'cost' && (!renderCostTab || (!!resource && !resourceLoading && !showCostTab))) ||
@@ -941,6 +980,33 @@ export function WorkloadView({
941
980
  />
942
981
  ) : (
943
982
  <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
983
+ {drawerSummary && (
984
+ <div className="px-4 pt-3" role="tablist" aria-label="Detail view">
985
+ <div className="inline-flex rounded-lg bg-theme-elevated p-0.5 text-xs">
986
+ {([['overview', 'Overview'], ['spec', 'Spec & status']] as const).map(([id, label]) => {
987
+ const on = id === 'spec' ? drawerSpec : !drawerSpec
988
+ return (
989
+ <button
990
+ key={id}
991
+ role="tab"
992
+ aria-selected={on}
993
+ onClick={() => setDrawerSpec(id === 'spec')}
994
+ className={clsx(
995
+ 'rounded-md px-2.5 py-1 font-medium transition-colors',
996
+ on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
997
+ )}
998
+ >
999
+ {label}
1000
+ </button>
1001
+ )
1002
+ })}
1003
+ </div>
1004
+ </div>
1005
+ )}
1006
+ {drawerSummary && !drawerSpec ? (
1007
+ drawerSummary
1008
+ ) : (
1009
+ <>
944
1010
  {renderOverviewLead && hasOperationalIssues && (
945
1011
  <div className="px-4 pt-4">
946
1012
  {renderOverviewLead({ kind, namespace, name })}
@@ -969,6 +1035,8 @@ export function WorkloadView({
969
1035
  updatesError={resourceFocusedUpdatesError}
970
1036
  mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'drawer' })}
971
1037
  />
1038
+ </>
1039
+ )}
972
1040
  </OperationalIssuesShownContext.Provider>
973
1041
  )}
974
1042
  </div>
@@ -1085,7 +1153,7 @@ export function WorkloadView({
1085
1153
  )}
1086
1154
  </>
1087
1155
  }
1088
- tabs={tabs}
1156
+ tabs={allTabs}
1089
1157
  activeTab={effectiveTab}
1090
1158
  onTabChange={handleSetTab}
1091
1159
  scopeControls={scopeControls}
@@ -1100,7 +1168,10 @@ export function WorkloadView({
1100
1168
  </div>
1101
1169
  )}
1102
1170
  <div className="min-h-0 flex-1">
1103
- {effectiveTab === 'overview' && expandedOverview ? (
1171
+ {activeExtraTab && <div className="h-full min-h-0 overflow-y-auto">{activeExtraTab.render()}</div>}
1172
+ {effectiveTab === 'overview' && expandedSummary ? (
1173
+ <div className="h-full min-h-0 overflow-y-auto">{expandedSummary}</div>
1174
+ ) : effectiveTab === 'overview' && expandedOverview ? (
1104
1175
  <div className="h-full min-h-0">
1105
1176
  {hasOperationalIssues && renderOverviewLead && (
1106
1177
  <div className="px-4 pt-4">
@@ -1109,7 +1180,7 @@ export function WorkloadView({
1109
1180
  )}
1110
1181
  {expandedOverview}
1111
1182
  </div>
1112
- ) : effectiveTab === 'overview' && (
1183
+ ) : (effectiveTab === 'overview' || effectiveTab === 'spec') && (
1113
1184
  <InfoTab
1114
1185
  resource={resource}
1115
1186
  selectedResource={selectedResource}
@@ -1846,6 +1917,7 @@ const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
1846
1917
  'clusterworkflowtemplates',
1847
1918
  'scaledjobs',
1848
1919
  'jobsets',
1920
+ 'clusters',
1849
1921
  ])
1850
1922
  const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
1851
1923
  const ROLLOUT_STATUS_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
@@ -1862,6 +1934,11 @@ export function supportsLogsWithoutPods(
1862
1934
  if (normalizedKind === 'jobsets') {
1863
1935
  return group === 'jobset.x-k8s.io' && apiVersion === 'jobset.x-k8s.io/v1alpha2'
1864
1936
  }
1937
+ // A CloudNativePG Cluster's instance Pods are its children, not related
1938
+ // Pods, and its logs are resolved server-side from the Cluster itself.
1939
+ if (normalizedKind === 'clusters') {
1940
+ return group === 'postgresql.cnpg.io' || !!apiVersion?.startsWith('postgresql.cnpg.io/')
1941
+ }
1865
1942
  return true
1866
1943
  }
1867
1944
 
@@ -3835,3 +3912,17 @@ function mergeAndRankEvents(events: TimelineEvent[], updates: TimelineEvent[]):
3835
3912
  return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
3836
3913
  })
3837
3914
  }
3915
+
3916
+ function mergeExtraTabs(tabs: DetailShellTab<TabType>[], extra: WorkloadExtraTab[] | undefined): DetailShellTab<TabType>[] {
3917
+ if (!extra || extra.length === 0) return tabs
3918
+ const replaced = new Set(extra.map((x) => x.replaces).filter(Boolean))
3919
+ const out = tabs.map((t) => (replaced.has(t.id) ? { ...t, hidden: true } : t))
3920
+ for (const x of extra) {
3921
+ const tab: DetailShellTab<TabType> = { id: x.id as TabType, label: x.label, icon: x.icon }
3922
+ const anchor = x.after ?? x.replaces
3923
+ const idx = anchor ? out.findIndex((t) => t.id === anchor) : -1
3924
+ if (idx >= 0) out.splice(idx + 1, 0, tab)
3925
+ else out.push(tab)
3926
+ }
3927
+ return out
3928
+ }
@@ -8,5 +8,6 @@ export {
8
8
  type ResourceOwnershipContext,
9
9
  type ServingResourceDetail,
10
10
  type WorkloadTabType,
11
+ type WorkloadExtraTab,
11
12
  } from './WorkloadView'
12
13
  export { ResourceDetailDrawer } from './ResourceDetailDrawer'
@@ -17,4 +17,10 @@ describe('supportsLogsWithoutPods', () => {
17
17
  expect(supportsLogsWithoutPods('jobs', 'Job', 'batch', 'batch/v1')).toBe(true)
18
18
  expect(supportsLogsWithoutPods('jobs', 'Job', 'example.io', 'example.io/v1')).toBe(false)
19
19
  })
20
+
21
+ it('offers a CloudNativePG Cluster its merged instance logs, but not other Cluster kinds', () => {
22
+ expect(supportsLogsWithoutPods('clusters', 'Cluster', 'postgresql.cnpg.io', 'postgresql.cnpg.io/v1')).toBe(true)
23
+ expect(supportsLogsWithoutPods('clusters', 'Cluster', undefined, 'postgresql.cnpg.io/v1')).toBe(true)
24
+ expect(supportsLogsWithoutPods('clusters', 'Cluster', 'cluster.x-k8s.io', 'cluster.x-k8s.io/v1beta1')).toBe(false)
25
+ })
20
26
  })
package/src/index.ts CHANGED
@@ -55,6 +55,9 @@ export * from './components/checks'
55
55
  // queue)
56
56
  export * from './components/issues'
57
57
 
58
+ // CloudNativePG workspace model (fleet derivation over /api/cnpg/workspace)
59
+ export * from './components/cnpg'
60
+
58
61
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
59
62
  export * from './components/cluster-switcher'
60
63
 
@@ -49,6 +49,12 @@ export function normalizeLevel(raw: unknown): LogLevel | null {
49
49
  return 'unknown'
50
50
  }
51
51
 
52
+ // PostgreSQL's error_severity values (every DEBUGn is written as DEBUG);
53
+ // anything else under `record` isn't PostgreSQL's.
54
+ const POSTGRES_SEVERITIES: Record<string, LogLevel> = {
55
+ DEBUG: 'debug', LOG: 'info', INFO: 'info', NOTICE: 'info', WARNING: 'warn', ERROR: 'error', FATAL: 'error', PANIC: 'error',
56
+ }
57
+
52
58
  const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level'] as const
53
59
 
54
60
  /**
@@ -57,6 +63,14 @@ const LEVEL_FIELD_KEYS = ['level', 'lvl', 'severity', 'levelname', 'log.level']
57
63
  * come from the same field.
58
64
  */
59
65
  export function selectLevelField(obj: Record<string, unknown>): { raw: unknown; level: LogLevel } | null {
66
+ // CloudNativePG wraps each PostgreSQL line in an instance-manager record
67
+ // whose own level is usually info; the database's severity is nested.
68
+ const pgRecord = obj.record
69
+ if (pgRecord && typeof pgRecord === 'object' && !Array.isArray(pgRecord)) {
70
+ const raw = (pgRecord as Record<string, unknown>).error_severity
71
+ const level = typeof raw === 'string' ? POSTGRES_SEVERITIES[raw.trim().toUpperCase()] : undefined
72
+ if (level) return { raw, level }
73
+ }
60
74
  for (const key of LEVEL_FIELD_KEYS) {
61
75
  const level = normalizeLevel(obj[key])
62
76
  if (level) return { raw: obj[key], level }