@skyhook-io/k8s-ui 1.16.2 → 1.17.0
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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/issue-timing.ts +2 -2
- package/src/components/issues/issues.test.ts +1 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/logs/useLogStream.ts +11 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +12 -9
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +2 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.tsx +21 -7
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -7
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +17 -33
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -20
- package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +2 -1
- package/src/components/timeline/TimelineList.tsx +187 -38
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/topology/TopologyGraph.tsx +5 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/drawer-components.test.tsx +66 -1
- package/src/components/ui/drawer-components.tsx +43 -29
- package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.tsx +216 -48
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/types/capacity.ts +10 -0
- package/src/types/core.ts +32 -1
- package/src/utils/format.test.ts +40 -2
- package/src/utils/format.ts +5 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/log-level.ts +14 -0
- package/src/utils/navigation.test.ts +13 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/resource-hierarchy.test.ts +32 -0
- package/src/utils/resource-hierarchy.ts +28 -2
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
|
@@ -40,6 +40,7 @@ import { isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/
|
|
|
40
40
|
import { toggleSet } from './GitOpsGraphFilterRail'
|
|
41
41
|
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
42
42
|
import { parseContextName } from '../../utils/context-name'
|
|
43
|
+
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
|
|
43
44
|
|
|
44
45
|
// =============================================================================
|
|
45
46
|
// GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
|
|
@@ -1373,8 +1374,8 @@ function GitOpsTable({
|
|
|
1373
1374
|
)}
|
|
1374
1375
|
<TableCell>
|
|
1375
1376
|
{row.terminating
|
|
1376
|
-
? <span className="text-orange-400/80">
|
|
1377
|
-
:
|
|
1377
|
+
? <span className="text-orange-400/80">{pendingFor(row.terminationStartedAt)}</span>
|
|
1378
|
+
: row.lastSync ? formatRelativeAgeTime(row.lastSync, '') : row.createdAt ? `Created ${formatRelativeAgeTime(row.createdAt, '')}` : ''}
|
|
1378
1379
|
</TableCell>
|
|
1379
1380
|
{showActions && onRowAction && (
|
|
1380
1381
|
<td
|
|
@@ -1603,9 +1604,9 @@ function GitOpsTile({
|
|
|
1603
1604
|
<div className="truncate font-mono text-[11px] text-theme-text-tertiary">{shortRevision(revision)}</div>
|
|
1604
1605
|
)}
|
|
1605
1606
|
{row.terminating ? (
|
|
1606
|
-
<div className="font-medium text-orange-400/80">
|
|
1607
|
+
<div className="font-medium text-orange-400/80">{pendingFor(row.terminationStartedAt)}</div>
|
|
1607
1608
|
) : (
|
|
1608
|
-
lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{
|
|
1609
|
+
lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAgeTime(lastSyncRaw, '')}</div>
|
|
1609
1610
|
)}
|
|
1610
1611
|
</div>
|
|
1611
1612
|
{(dest || ns || row.project) && (
|
|
@@ -1926,26 +1927,10 @@ function compactClusterURL(dest: string): string {
|
|
|
1926
1927
|
.replace(/^kubernetes\.default\.svc(:\d+)?\/?$/, 'in-cluster')
|
|
1927
1928
|
}
|
|
1928
1929
|
|
|
1929
|
-
//
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
if (!rfc3339) return ''
|
|
1934
|
-
const time = Date.parse(rfc3339)
|
|
1935
|
-
if (!Number.isFinite(time)) return ''
|
|
1936
|
-
const diffMs = Date.now() - time
|
|
1937
|
-
const s = Math.max(0, Math.floor(diffMs / 1000))
|
|
1938
|
-
if (s < 60) return `${s}s ago`
|
|
1939
|
-
const m = Math.floor(s / 60)
|
|
1940
|
-
if (m < 60) return `${m}m ago`
|
|
1941
|
-
const h = Math.floor(m / 60)
|
|
1942
|
-
if (h < 24) return `${h}h ago`
|
|
1943
|
-
const d = Math.floor(h / 24)
|
|
1944
|
-
if (d < 30) return `${d}d ago`
|
|
1945
|
-
const mo = Math.floor(d / 30)
|
|
1946
|
-
if (mo < 12) return `${mo}mo ago`
|
|
1947
|
-
const y = Math.floor(d / 365)
|
|
1948
|
-
return `${y}y ago`
|
|
1930
|
+
// How long deletion has been pending, not when it started.
|
|
1931
|
+
function pendingFor(startedAt?: string): string {
|
|
1932
|
+
const age = formatCompactAge(startedAt)
|
|
1933
|
+
return age ? `Pending for ${age}` : 'Pending'
|
|
1949
1934
|
}
|
|
1950
1935
|
|
|
1951
1936
|
// =============================================================================
|
|
@@ -82,7 +82,7 @@ export function describeGitOpsTerminating(summary?: {
|
|
|
82
82
|
finalizers?: string[]
|
|
83
83
|
}): { chipTooltip: string; actionDisabledTooltip: string } {
|
|
84
84
|
const ageText = formatCompactAge(summary?.terminationStartedAt)
|
|
85
|
-
const ageSuffix = ageText ? ` ${ageText}
|
|
85
|
+
const ageSuffix = ageText ? ` for ${ageText}` : ''
|
|
86
86
|
const finalizers = summary?.finalizers ?? []
|
|
87
87
|
const finSuffix = finalizers.length > 0 ? ` Finalizers: ${finalizers.join(', ')}.` : ''
|
|
88
88
|
const ageInline = ageText ? ` (${ageText})` : ''
|
|
@@ -72,7 +72,7 @@ export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitO
|
|
|
72
72
|
const revision = summary.lastRevision || summary.targetRevision || ''
|
|
73
73
|
const shortRev = shortRevisionForCommit(revision)
|
|
74
74
|
const commitUrl = revision ? commitURLForRepo(summary.source, revision) : null
|
|
75
|
-
const reconcileAge =
|
|
75
|
+
const reconcileAge = formatRelativeAgeTime(summary.lastReconcile)
|
|
76
76
|
const healthSummary = buildHealthSummary(insight.changes ?? [])
|
|
77
77
|
// STUCK belongs with the operation chip — it's a property of the same
|
|
78
78
|
// state ("failed AND won't self-recover"). Co-locating FAILED + STUCK ·
|
|
@@ -257,7 +257,7 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
|
|
|
257
257
|
// disclosure — still available for forensics but not noise during deletion.
|
|
258
258
|
function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsight['summary']> }) {
|
|
259
259
|
const [showHistorical, setShowHistorical] = useState(false)
|
|
260
|
-
const pending =
|
|
260
|
+
const pending = formatRelativeAgeTime(summary.terminationStartedAt) || 'recently'
|
|
261
261
|
const finalizers = summary.finalizers ?? []
|
|
262
262
|
return (
|
|
263
263
|
<div className="border-b border-orange-500/20 bg-orange-500/[0.04] px-4 py-2">
|
|
@@ -282,7 +282,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
|
|
|
282
282
|
{(summary.lastRevision || summary.targetRevision) && (
|
|
283
283
|
<MetaFact label="Revision" value={summary.lastRevision || summary.targetRevision || '-'} mono />
|
|
284
284
|
)}
|
|
285
|
-
{summary.lastReconcile && <MetaFact label="Last reconcile" value={
|
|
285
|
+
{summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelativeAgeTime(summary.lastReconcile)} />}
|
|
286
286
|
{summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
|
|
287
287
|
</div>
|
|
288
288
|
</Collapse>
|
|
@@ -1628,7 +1628,7 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
|
|
|
1628
1628
|
{e.count && e.count > 1 && (
|
|
1629
1629
|
<span className="text-[10px] text-theme-text-tertiary">×{e.count}</span>
|
|
1630
1630
|
)}
|
|
1631
|
-
<span className="ml-auto text-[10px] text-theme-text-tertiary">{
|
|
1631
|
+
<span className="ml-auto text-[10px] text-theme-text-tertiary">{formatRelativeAgeTime(e.lastTimestamp, '')}</span>
|
|
1632
1632
|
</div>
|
|
1633
1633
|
<p className="mt-0.5 text-theme-text-secondary">{e.message}</p>
|
|
1634
1634
|
</div>
|
|
@@ -1639,10 +1639,6 @@ function RecentEventsPanel({ events }: { events: NonNullable<GitOpsChange['recen
|
|
|
1639
1639
|
)
|
|
1640
1640
|
}
|
|
1641
1641
|
|
|
1642
|
-
function formatRelativeTime(value: string): string {
|
|
1643
|
-
return formatRelativeAgeTime(value, '')
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
1642
|
interface GitOpsActivityInsightViewProps {
|
|
1647
1643
|
insight?: GitOpsInsight | null
|
|
1648
1644
|
error?: Error | null
|
|
@@ -1729,7 +1725,7 @@ function HistoryRows({
|
|
|
1729
1725
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
1730
1726
|
<span className="font-mono text-xs text-theme-text-primary">{item.revision || item.phase || '-'}</span>
|
|
1731
1727
|
<PhaseChip phase={item.phase} message={item.message} />
|
|
1732
|
-
<span className="text-[11px] text-theme-text-tertiary">{
|
|
1728
|
+
<span className="text-[11px] text-theme-text-tertiary">{formatRelativeAgeTime(item.deployedAt)}</span>
|
|
1733
1729
|
{item.initiatedBy && (
|
|
1734
1730
|
<span className="text-[11px] text-theme-text-tertiary">by {item.initiatedBy}</span>
|
|
1735
1731
|
)}
|
|
@@ -1831,6 +1827,3 @@ function InsightErrorState({ error }: { error: Error }) {
|
|
|
1831
1827
|
)
|
|
1832
1828
|
}
|
|
1833
1829
|
|
|
1834
|
-
function formatRelative(value?: string) {
|
|
1835
|
-
return formatRelativeAgeTime(value)
|
|
1836
|
-
}
|
|
@@ -752,10 +752,10 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
752
752
|
// detail title row and fleet rows.
|
|
753
753
|
if (isNodeTerminating(node)) {
|
|
754
754
|
chips.push({ value: 'Terminating', tone: 'warning' })
|
|
755
|
-
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value:
|
|
755
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
|
|
756
756
|
return chips
|
|
757
757
|
}
|
|
758
|
-
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value:
|
|
758
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
|
|
759
759
|
const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
|
|
760
760
|
if (revision) chips.push({ label: 'rev', value: revision })
|
|
761
761
|
const attempted = stringData(data.attemptedRevision)
|
|
@@ -886,6 +886,3 @@ function pluralize(kind: string): string {
|
|
|
886
886
|
return `${kind}s`
|
|
887
887
|
}
|
|
888
888
|
|
|
889
|
-
function formatAge(timestamp: string): string {
|
|
890
|
-
return formatCompactAge(timestamp)
|
|
891
|
-
}
|
|
@@ -403,7 +403,16 @@ export function IssueRow({
|
|
|
403
403
|
function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSource }) {
|
|
404
404
|
const crash =
|
|
405
405
|
issue.restart_count || issue.last_terminated_reason
|
|
406
|
-
? [
|
|
406
|
+
? [
|
|
407
|
+
issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null,
|
|
408
|
+
issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null,
|
|
409
|
+
issue.restart_loop?.workload_pods && issue.restart_loop.workload_pods > 1
|
|
410
|
+
? `${issue.restart_loop.looping_pods} of ${issue.restart_loop.workload_pods} pods looping`
|
|
411
|
+
: null,
|
|
412
|
+
issue.restart_loop?.startup_probe_failure ? 'startup probe failures seen' : null,
|
|
413
|
+
issue.restart_loop?.liveness_probe_failure ? 'liveness probe failures seen' : null,
|
|
414
|
+
issue.restart_loop?.readiness_probe_failure ? 'readiness probe failures seen' : null,
|
|
415
|
+
]
|
|
407
416
|
.filter(Boolean)
|
|
408
417
|
.join(' · ')
|
|
409
418
|
: null;
|
|
@@ -113,9 +113,9 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
|
|
|
113
113
|
}
|
|
114
114
|
return {
|
|
115
115
|
kind: 'regression',
|
|
116
|
-
chip: 'after healthy',
|
|
116
|
+
chip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'NotReady' : 'after healthy',
|
|
117
117
|
meta: 'failing evidence followed a healthy period',
|
|
118
|
-
tooltip: 'A healthy period preceded this failing evidence.',
|
|
118
|
+
tooltip: issue.kind === 'Node' && issue.reason === 'NotReady' ? 'The Ready condition stopped reporting healthy at this time.' : 'A healthy period preceded this failing evidence.',
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
case 'started_at_resource_creation': {
|
|
@@ -139,7 +139,7 @@ describe('IssueRow', () => {
|
|
|
139
139
|
|
|
140
140
|
it('uses the category-group chip class in the queue row', () => {
|
|
141
141
|
const html = renderToString(createElement(IssueRow, {
|
|
142
|
-
issue: mk({ category_group: 'control_plane' }),
|
|
142
|
+
issue: mk({ category_group: 'control_plane', kind: 'Node' }),
|
|
143
143
|
open: false,
|
|
144
144
|
onToggle: () => undefined,
|
|
145
145
|
}))
|
|
@@ -230,6 +230,9 @@ export interface Issue {
|
|
|
230
230
|
// Pod crash context carried from the representative member.
|
|
231
231
|
restart_count?: number;
|
|
232
232
|
last_terminated_reason?: string;
|
|
233
|
+
/** Evidence for a container in an active restart loop (crashloop issues).
|
|
234
|
+
* Probe failures are observations alongside the restarts, not a cause. */
|
|
235
|
+
restart_loop?: IssueRestartLoop;
|
|
233
236
|
|
|
234
237
|
/**
|
|
235
238
|
* Best-effort timing evidence from K8s-native signals. Absent when Radar has
|
|
@@ -360,3 +363,24 @@ export function issueMessageParts(issue: Issue): { headline: string; detail: str
|
|
|
360
363
|
if (normalized && normalized !== raw) return { headline: normalized, detail: raw };
|
|
361
364
|
return { headline: raw, detail: '' };
|
|
362
365
|
}
|
|
366
|
+
|
|
367
|
+
export interface IssueProbeFailure {
|
|
368
|
+
last_seen: string;
|
|
369
|
+
message?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface IssueRestartLoop {
|
|
373
|
+
container: string;
|
|
374
|
+
sidecar?: boolean;
|
|
375
|
+
restart_count: number;
|
|
376
|
+
last_exit_code: number;
|
|
377
|
+
last_reason?: string;
|
|
378
|
+
last_finished_at?: string;
|
|
379
|
+
last_started_at?: string;
|
|
380
|
+
looping_pods?: number;
|
|
381
|
+
workload_pods?: number;
|
|
382
|
+
severity_reason?: string;
|
|
383
|
+
liveness_probe_failure?: IssueProbeFailure;
|
|
384
|
+
readiness_probe_failure?: IssueProbeFailure;
|
|
385
|
+
startup_probe_failure?: IssueProbeFailure;
|
|
386
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ToastProvider } from '../ui/Toast'
|
|
6
|
+
import { WorkloadLogsViewer } from './WorkloadLogsViewer'
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
|
+
|
|
10
|
+
class FakeEventSource extends EventTarget {
|
|
11
|
+
static last: FakeEventSource | undefined
|
|
12
|
+
constructor(readonly url: string) {
|
|
13
|
+
super()
|
|
14
|
+
FakeEventSource.last = this
|
|
15
|
+
}
|
|
16
|
+
close() {}
|
|
17
|
+
emit(type: string, data: unknown) {
|
|
18
|
+
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let root: Root
|
|
23
|
+
let element: HTMLDivElement
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.stubGlobal('EventSource', FakeEventSource)
|
|
26
|
+
element = document.createElement('div')
|
|
27
|
+
document.body.appendChild(element)
|
|
28
|
+
root = createRoot(element)
|
|
29
|
+
})
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await act(async () => root.unmount())
|
|
32
|
+
element.remove()
|
|
33
|
+
vi.unstubAllGlobals()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('clears the unreadable-sources notice when the stream ends', async () => {
|
|
37
|
+
const notice = '1 source could not be read: web-0/app: Kubernetes did not return this container\'s logs'
|
|
38
|
+
await act(async () =>
|
|
39
|
+
root.render(
|
|
40
|
+
<ToastProvider>
|
|
41
|
+
<WorkloadLogsViewer
|
|
42
|
+
name="web"
|
|
43
|
+
fetchAll={async () => ({ pods: [], logs: [] })}
|
|
44
|
+
createStream={() => new EventSource('/stream')}
|
|
45
|
+
autoStream
|
|
46
|
+
/>
|
|
47
|
+
</ToastProvider>,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
const source = FakeEventSource.last
|
|
51
|
+
if (!source) throw new Error('stream was not opened')
|
|
52
|
+
|
|
53
|
+
await act(async () => source.emit('connected', { pods: [{ name: 'web-0', containers: ['app'] }] }))
|
|
54
|
+
await act(async () => source.emit('notice', { notice }))
|
|
55
|
+
expect(element.textContent).toContain(notice)
|
|
56
|
+
|
|
57
|
+
await act(async () => source.emit('end', { reason: 'no pods found' }))
|
|
58
|
+
expect(element.textContent).not.toContain(notice)
|
|
59
|
+
})
|
|
@@ -63,9 +63,15 @@ export interface WorkloadLogsViewerProps {
|
|
|
63
63
|
* re-armed. Requires `createStream`. Default: false.
|
|
64
64
|
*/
|
|
65
65
|
autoStream?: boolean
|
|
66
|
+
/** Pods selected when the pod list first loads; all pods when empty or none match. */
|
|
67
|
+
initialPods?: string[]
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false }: WorkloadLogsViewerProps) {
|
|
70
|
+
export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods }: WorkloadLogsViewerProps) {
|
|
71
|
+
const initialSelection = (names: string[]) => {
|
|
72
|
+
const wanted = names.filter((n) => initialPods?.includes(n))
|
|
73
|
+
return new Set(wanted.length > 0 ? wanted : names)
|
|
74
|
+
}
|
|
69
75
|
const [selectedContainer, setSelectedContainer] = useState<string>('')
|
|
70
76
|
const [pods, setPods] = useState<WorkloadPodInfo[]>([])
|
|
71
77
|
const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
|
|
@@ -129,7 +135,9 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
129
135
|
const previousPods = previousSnapshotPods.current
|
|
130
136
|
const nextPods = resultPods.map(p => p.name)
|
|
131
137
|
previousSnapshotPods.current = nextPods
|
|
132
|
-
setSelectedPods(selected => previousPods === null
|
|
138
|
+
setSelectedPods(selected => previousPods === null
|
|
139
|
+
? initialSelection(nextPods)
|
|
140
|
+
: previousPods.every(pod => selected.has(pod))
|
|
133
141
|
? new Set(nextPods)
|
|
134
142
|
: new Set(nextPods.filter(pod => selected.has(pod))))
|
|
135
143
|
|
|
@@ -194,7 +202,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
194
202
|
setEmptyMessage(data.emptyMessage || null)
|
|
195
203
|
setEmptyCommand(data.command || null)
|
|
196
204
|
setSelectedPods(prev => (
|
|
197
|
-
prev.size === 0 ?
|
|
205
|
+
prev.size === 0 ? initialSelection(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
|
|
198
206
|
))
|
|
199
207
|
}
|
|
200
208
|
},
|
|
@@ -205,6 +213,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
205
213
|
content: data.content || '',
|
|
206
214
|
container: data.container || '',
|
|
207
215
|
pod: data.pod || '',
|
|
216
|
+
sourceLabel: data.sourceLabel,
|
|
208
217
|
podColorIndex: podColorIndexRef.current.get(data.pod || ''),
|
|
209
218
|
})
|
|
210
219
|
}
|
|
@@ -237,7 +246,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
237
246
|
// while new pod logs start flowing in
|
|
238
247
|
}
|
|
239
248
|
},
|
|
249
|
+
onNotice: (data: unknown) => {
|
|
250
|
+
const notice = typeof data === 'object' && data !== null && 'notice' in data ? data.notice : ''
|
|
251
|
+
setNotice(typeof notice === 'string' ? notice : '')
|
|
252
|
+
},
|
|
240
253
|
onEnd: (data: any) => {
|
|
254
|
+
setNotice('')
|
|
241
255
|
if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
|
|
242
256
|
if (data?.command) setEmptyCommand(data.command)
|
|
243
257
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { detectLogLevel } from './useLogBuffer'
|
|
3
|
+
|
|
4
|
+
describe('detectLogLevel with CloudNativePG records', () => {
|
|
5
|
+
it('uses the nested PostgreSQL severity over the instance manager level', () => {
|
|
6
|
+
const line = (sev: string) => JSON.stringify({ level: 'info', logger: 'postgres', msg: 'record', record: { error_severity: sev, message: 'x' } })
|
|
7
|
+
expect(detectLogLevel(line('ERROR'))).toBe('error')
|
|
8
|
+
expect(detectLogLevel(line('FATAL'))).toBe('error')
|
|
9
|
+
expect(detectLogLevel(line('WARNING'))).toBe('warn')
|
|
10
|
+
expect(detectLogLevel(line('LOG'))).toBe('info')
|
|
11
|
+
expect(detectLogLevel(line('DEBUG'))).toBe('debug')
|
|
12
|
+
})
|
|
13
|
+
it('ignores a record that is not a PostgreSQL one', () => {
|
|
14
|
+
expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'request failed', record: { error_severity: 20 } }))).toBe('error')
|
|
15
|
+
expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'x', record: { error_severity: 'minor' } }))).toBe('error')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -10,6 +10,8 @@ export interface LogStreamHandlers {
|
|
|
10
10
|
onPodAdded?: (data: unknown) => void
|
|
11
11
|
/** Called when pods are terminated during streaming (workload logs only) */
|
|
12
12
|
onPodRemoved?: (data: unknown) => void
|
|
13
|
+
/** Called when the set of sources the stream cannot read changes (workload logs only) */
|
|
14
|
+
onNotice?: (data: unknown) => void
|
|
13
15
|
/** Called when the server ends the stream cleanly */
|
|
14
16
|
onEnd?: (data: unknown) => void
|
|
15
17
|
}
|
|
@@ -92,6 +94,15 @@ export function useLogStream() {
|
|
|
92
94
|
}
|
|
93
95
|
})
|
|
94
96
|
|
|
97
|
+
es.addEventListener('notice', (event) => {
|
|
98
|
+
if (!isCurrent()) return
|
|
99
|
+
if (handlers.onNotice) {
|
|
100
|
+
try { handlers.onNotice(JSON.parse((event as MessageEvent).data)) } catch (e) {
|
|
101
|
+
console.error('Failed to parse notice event:', e)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
95
106
|
es.addEventListener('end', (event) => {
|
|
96
107
|
if (!isCurrent()) return
|
|
97
108
|
endedRef.current = true
|
|
@@ -105,3 +105,79 @@ describe('ResourcesSidebar count visibility', () => {
|
|
|
105
105
|
expect(html).toContain('HorizontalPodAutoscaler')
|
|
106
106
|
})
|
|
107
107
|
})
|
|
108
|
+
|
|
109
|
+
describe('ResourcesSidebar category workspaces', () => {
|
|
110
|
+
const cnpgCluster: APIResource = {
|
|
111
|
+
group: 'postgresql.cnpg.io',
|
|
112
|
+
version: 'v1',
|
|
113
|
+
kind: 'Cluster',
|
|
114
|
+
name: 'clusters',
|
|
115
|
+
namespaced: true,
|
|
116
|
+
isCrd: true,
|
|
117
|
+
verbs: ['list'],
|
|
118
|
+
}
|
|
119
|
+
const objectStore: APIResource = {
|
|
120
|
+
group: 'barmancloud.cnpg.io',
|
|
121
|
+
version: 'v1',
|
|
122
|
+
kind: 'ObjectStore',
|
|
123
|
+
name: 'objectstores',
|
|
124
|
+
namespaced: true,
|
|
125
|
+
isCrd: true,
|
|
126
|
+
verbs: ['list'],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('renders destinations above the kinds, with the active object nested and no kind selected', () => {
|
|
130
|
+
const html = renderToString(
|
|
131
|
+
<ResourcesSidebar
|
|
132
|
+
selectedKind={null}
|
|
133
|
+
onSelectedKindChange={() => {}}
|
|
134
|
+
apiResources={[cnpgCluster, objectStore]}
|
|
135
|
+
resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
|
|
136
|
+
categoryWorkspaces={{
|
|
137
|
+
CloudNativePG: {
|
|
138
|
+
destinations: [
|
|
139
|
+
{ id: 'overview', label: 'Overview', count: 3, countTitle: '3 clusters need attention', active: true, child: { label: 'pg-orders' }, onSelect: () => {} },
|
|
140
|
+
],
|
|
141
|
+
defaultKindsCollapsed: true,
|
|
142
|
+
scopeNote: 'Counts for namespace payments',
|
|
143
|
+
},
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
expect(html).toContain('Workspace')
|
|
148
|
+
expect(html).toContain('Overview')
|
|
149
|
+
expect(html).toContain('pg-orders')
|
|
150
|
+
expect(html).toContain('Counts for namespace payments')
|
|
151
|
+
expect(html).toContain('Resource kinds')
|
|
152
|
+
expect(html).toMatch(/aria-expanded="false"[^>]*>(?:(?!<\/button>).)*Resource kinds/)
|
|
153
|
+
expect(html).not.toContain('selection-strong selection-text">Pod')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps a workspace category visible when it has no resources', () => {
|
|
157
|
+
const html = renderToString(
|
|
158
|
+
<ResourcesSidebar
|
|
159
|
+
selectedKind={null}
|
|
160
|
+
onSelectedKindChange={() => {}}
|
|
161
|
+
apiResources={[cnpgCluster]}
|
|
162
|
+
resourceCounts={{ 'postgresql.cnpg.io/Cluster': 0 }}
|
|
163
|
+
categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }] } }}
|
|
164
|
+
/>
|
|
165
|
+
)
|
|
166
|
+
expect(html).toContain('CloudNativePG')
|
|
167
|
+
expect(html).toContain('Overview')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('labels API groups when a workspace category spans several', () => {
|
|
171
|
+
const html = renderToString(
|
|
172
|
+
<ResourcesSidebar
|
|
173
|
+
selectedKind={{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io' }}
|
|
174
|
+
onSelectedKindChange={() => {}}
|
|
175
|
+
apiResources={[cnpgCluster, objectStore]}
|
|
176
|
+
resourceCounts={{ 'postgresql.cnpg.io/Cluster': 2, 'barmancloud.cnpg.io/ObjectStore': 1 }}
|
|
177
|
+
categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', onSelect: () => {} }], defaultKindsCollapsed: true } }}
|
|
178
|
+
/>
|
|
179
|
+
)
|
|
180
|
+
expect(html).toContain('postgresql.cnpg.io')
|
|
181
|
+
expect(html).toContain('barmancloud.cnpg.io')
|
|
182
|
+
})
|
|
183
|
+
})
|