@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.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.test.tsx +136 -8
- package/src/components/applications/ApplicationDetail.tsx +1215 -487
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +10 -3
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
- package/src/components/issues/IssuesView.tsx +55 -29
- package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
- package/src/components/issues/ResourceIssuesSection.tsx +21 -1
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.tsx +109 -19
- package/src/components/resources/column-filter-serialization.test.ts +79 -1
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils.ts +94 -25
- package/src/components/shared/ResourceActionsBar.tsx +54 -22
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +7 -3
- package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyGraph.tsx +76 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +23 -15
- package/src/components/topology/index.ts +2 -0
- package/src/components/ui/Badge.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/Tooltip.test.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +9 -12
- package/src/components/ui/drawer-components.tsx +9 -6
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +24 -3
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +1 -0
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/api-resources.test.ts +36 -1
- package/src/utils/api-resources.ts +16 -0
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/applications.test.ts +34 -0
- package/src/utils/applications.ts +25 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/resource-hierarchy.test.ts +35 -1
- package/src/utils/resource-hierarchy.ts +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
|
|
1
|
+
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
|
2
2
|
import { PaneLoader } from '../../ui/PaneLoader'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
entryTone,
|
|
21
21
|
gitopsToSeverity,
|
|
22
22
|
healthStatusRank,
|
|
23
|
+
isArgoResourceSyncEligible,
|
|
23
24
|
messageToPhase,
|
|
24
25
|
normalizeHealthStatus,
|
|
25
26
|
normalizeSyncStatus,
|
|
@@ -32,6 +33,10 @@ import {
|
|
|
32
33
|
interface GitOpsStatusStripProps {
|
|
33
34
|
insight?: GitOpsInsight | null
|
|
34
35
|
loading?: boolean
|
|
36
|
+
// Optional host slot: given the latest revision, render inline Git commit
|
|
37
|
+
// metadata (author, signature) next to the revision SHA. The host wires this
|
|
38
|
+
// to a data loader; when absent, only the SHA is shown.
|
|
39
|
+
renderRevisionMeta?: (revision: string) => ReactNode
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
// Status strip carries the operation chip (when a sync is in flight or
|
|
@@ -51,7 +56,7 @@ interface GitOpsStatusStripProps {
|
|
|
51
56
|
//
|
|
52
57
|
// Health and Sync badges live next to the title in the page header —
|
|
53
58
|
// pair them there with identity, not here.
|
|
54
|
-
export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps) {
|
|
59
|
+
export function GitOpsStatusStrip({ insight, loading, renderRevisionMeta }: GitOpsStatusStripProps) {
|
|
55
60
|
const summary = insight?.summary
|
|
56
61
|
if (loading) {
|
|
57
62
|
return <div className="h-8 animate-pulse border-b border-theme-border bg-theme-base" />
|
|
@@ -135,6 +140,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
|
|
|
135
140
|
</Tooltip>
|
|
136
141
|
)}
|
|
137
142
|
{reconcileAge && <span className="text-theme-text-tertiary">· {reconcileAge}</span>}
|
|
143
|
+
{revision && renderRevisionMeta?.(revision)}
|
|
138
144
|
</span>
|
|
139
145
|
)}
|
|
140
146
|
{!shortRev && reconcileAge && <MetaFact label="Last reconcile" value={reconcileAge} />}
|
|
@@ -223,11 +229,13 @@ function buildHealthSummary(changes: GitOpsChange[]): { text: string; tone: stri
|
|
|
223
229
|
let outOfSync = 0
|
|
224
230
|
let other = 0
|
|
225
231
|
for (const c of changes) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
else if (
|
|
232
|
+
// Count HEALTH, not sync — a Synced resource can still be Degraded, so check
|
|
233
|
+
// degraded/missing FIRST and never let a Synced sync-state mask it. A Synced
|
|
234
|
+
// resource with no assessed health (ConfigMaps etc.) still reads as fine.
|
|
235
|
+
if (c.health === 'Degraded' || c.category === 'Degraded') degraded++
|
|
236
|
+
else if (c.health === 'Missing' || c.category === 'Missing') missing++
|
|
237
|
+
else if (c.health === 'Healthy' || c.category === 'Synced') healthy++
|
|
238
|
+
else if (c.category === 'OutOfSync') outOfSync++
|
|
231
239
|
else other++
|
|
232
240
|
}
|
|
233
241
|
const total = changes.length
|
|
@@ -283,7 +291,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
|
|
|
283
291
|
|
|
284
292
|
function isInFlightPhase(phase: string): boolean {
|
|
285
293
|
const p = phase.toLowerCase()
|
|
286
|
-
return p.includes('running') || p.includes('progress') || p.includes('reconcil')
|
|
294
|
+
return p.includes('running') || p.includes('terminat') || p.includes('progress') || p.includes('reconcil')
|
|
287
295
|
}
|
|
288
296
|
|
|
289
297
|
// Show the operation chip only for phases the operator needs to *act on*.
|
|
@@ -703,6 +711,8 @@ interface GitOpsChangesViewProps {
|
|
|
703
711
|
insight?: GitOpsInsight | null
|
|
704
712
|
error?: Error | null
|
|
705
713
|
onOpenResource?: (ref: GitOpsChange['ref']) => void
|
|
714
|
+
onSyncResource?: (ref: GitOpsChange['ref']) => void
|
|
715
|
+
syncResourceDisabledReason?: string
|
|
706
716
|
// When set, the matching change row scrolls into view and gets a transient
|
|
707
717
|
// highlight ring. Used when the user clicks "View →" on an issue alert in
|
|
708
718
|
// the band above. Key shape: `${kind}/${namespace||''}/${name}` (group is
|
|
@@ -714,6 +724,15 @@ interface GitOpsChangesViewProps {
|
|
|
714
724
|
// Argo's default list-view behavior. Default mode still shows declared
|
|
715
725
|
// resources only — the diagnostic data (drift, events) lives there.
|
|
716
726
|
tree?: GitOpsResourceTree | null
|
|
727
|
+
// Host-wired loader for the full Git-rendered desired-vs-live diff of a
|
|
728
|
+
// single managed resource (Argo CD only). Data fetching lives in web/; this
|
|
729
|
+
// returns the loaded <ArgoResourceDiff/>. When present AND
|
|
730
|
+
// insight.capabilities.argoDiffAvailable, resolvable rows expose a "Full
|
|
731
|
+
// diff" toggle. Library consumers that don't wire it get no such affordance.
|
|
732
|
+
renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
|
|
733
|
+
// Opens the global Settings dialog. Backs the "Connect Argo CD" hint shown
|
|
734
|
+
// when the root is an Argo Application without the diff integration.
|
|
735
|
+
onOpenSettings?: () => void
|
|
717
736
|
}
|
|
718
737
|
|
|
719
738
|
// Status facets for the Resources list. OutOfSync is a sync-status concern
|
|
@@ -724,7 +743,7 @@ const STATUS_FACETS: { key: ResourceStatusFacet; label: string; tone: FilterPill
|
|
|
724
743
|
{ key: 'missing', label: 'Missing', tone: 'danger' },
|
|
725
744
|
]
|
|
726
745
|
|
|
727
|
-
export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tree }: GitOpsChangesViewProps) {
|
|
746
|
+
export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResource, syncResourceDisabledReason, focusKey, tree, renderResourceDiff, onOpenSettings }: GitOpsChangesViewProps) {
|
|
728
747
|
// "All resources" toggle: when on, render generated descendants alongside
|
|
729
748
|
// the controller's declared inventory. Argo's UI defaults to "all" — we
|
|
730
749
|
// default to "declared" because the diagnostic data (drift, events) lives
|
|
@@ -741,6 +760,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
741
760
|
const [statusFilters, setStatusFilters] = useState<Set<ResourceStatusFacet>>(new Set())
|
|
742
761
|
const [sort, setSort] = useState<{ key: ResourceSortKey; dir: SortDir }>({ key: 'order', dir: 'asc' })
|
|
743
762
|
const changes = insight?.changes ?? []
|
|
763
|
+
// Keep object identity here: tree extras are newly allocated, while ordered
|
|
764
|
+
// controller changes retain their original references.
|
|
765
|
+
const declaredChanges = new Set(changes)
|
|
744
766
|
const plan = insight?.plan ?? []
|
|
745
767
|
// Synthesize Change rows for generated tree nodes that aren't already in
|
|
746
768
|
// the declared inventory. These rows carry less diagnostic data — no
|
|
@@ -823,6 +845,23 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
823
845
|
// doesn't exist), but we CAN point at where they're declared in Git —
|
|
824
846
|
// which is the most useful thing to do when there's no drawer to open.
|
|
825
847
|
const sourceTreeURL = gitTreeURL(insight.summary.source, insight.summary.lastRevision || insight.summary.targetRevision || '')
|
|
848
|
+
// Comparison-coverage disclosure (Argo only). spec.ignoreDifferences hides
|
|
849
|
+
// fields from drift comparison; surfacing the count keeps the drift view
|
|
850
|
+
// honest about what it's NOT checking. Neutral note, not an alarm.
|
|
851
|
+
const ignoredDiffs = insight.summary.ignoredDifferences
|
|
852
|
+
const isArgoRoot = insight.summary.tool === 'argocd' && insight.summary.kind === 'Application'
|
|
853
|
+
// Full-diff affordance is offered only when the backend reports the Argo CD
|
|
854
|
+
// integration is connected for this app AND the host wired the loader.
|
|
855
|
+
const argoDiffAvailable = isArgoRoot && !!insight.capabilities?.argoDiffAvailable && !!renderResourceDiff
|
|
856
|
+
// When Argo can't build the desired state (ComparisonError — unreachable repo,
|
|
857
|
+
// missing revision, broken spec), it can't compare live-vs-Git for ANY
|
|
858
|
+
// resource, so every row's sync arrives Unknown. Those per-row Unknowns are
|
|
859
|
+
// all shadows of the one app-level failure already surfaced in the issues
|
|
860
|
+
// band above; rendering them as N alarming pills makes one problem look like
|
|
861
|
+
// many. When the app-level sync is Unknown, quiet the derivative per-row sync
|
|
862
|
+
// to a muted placeholder and explain the cause once. Health stays — it's a
|
|
863
|
+
// live signal that's still knowable and still worth reading.
|
|
864
|
+
const syncUnavailable = isArgoRoot && (insight.summary.sync ?? '').toLowerCase() === 'unknown'
|
|
826
865
|
const toggleFacet = (facet: ResourceStatusFacet) => {
|
|
827
866
|
setStatusFilters((prev) => {
|
|
828
867
|
const next = new Set(prev)
|
|
@@ -887,6 +926,17 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
887
926
|
</div>
|
|
888
927
|
)}
|
|
889
928
|
</div>
|
|
929
|
+
{syncUnavailable && totalCount > 0 && (
|
|
930
|
+
<div className="flex items-start gap-2 border-b border-theme-border bg-theme-base/40 px-4 py-2 text-xs text-theme-text-secondary">
|
|
931
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
932
|
+
<span>
|
|
933
|
+
Argo CD hasn't compared this app against Git yet, so per-resource
|
|
934
|
+
<span className="font-medium text-theme-text-primary"> sync</span> status is unavailable
|
|
935
|
+
(a first refresh may still be in flight; if an error is shown above, resolve it to restore comparison).
|
|
936
|
+
<span className="font-medium text-theme-text-primary"> Health</span> below is still live.
|
|
937
|
+
</span>
|
|
938
|
+
</div>
|
|
939
|
+
)}
|
|
890
940
|
{/* Filter/sort toolbar. The status facets are the primary way to
|
|
891
941
|
answer "which resources are the problem?" now that the issues band
|
|
892
942
|
no longer restates every OutOfSync resource above. */}
|
|
@@ -931,8 +981,11 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
931
981
|
{/* Honest disclaimer about diff scope. Neither Argo nor Flux exposes
|
|
932
982
|
per-resource desired-vs-live diffs on the CRD — they're computed
|
|
933
983
|
on demand by their respective servers/CLIs, which Radar doesn't
|
|
934
|
-
call.
|
|
935
|
-
|
|
984
|
+
call. Suppressed for an Argo app that shows the connect/reconnect bar
|
|
985
|
+
below (which points at Settings), so the two don't stack with subtly
|
|
986
|
+
different advice — the CLI fallback stays for Flux and for consumers
|
|
987
|
+
that don't wire Settings. */}
|
|
988
|
+
{totalCount > 0 && !argoDiffAvailable && !(isArgoRoot && !!onOpenSettings) && (
|
|
936
989
|
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
937
990
|
Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
|
|
938
991
|
insight.summary.kind === 'HelmRelease' ? (
|
|
@@ -945,6 +998,56 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
945
998
|
)}
|
|
946
999
|
</div>
|
|
947
1000
|
)}
|
|
1001
|
+
{totalCount > 0 && isArgoRoot && !insight.capabilities?.argoDiffAvailable && onOpenSettings && (
|
|
1002
|
+
<div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
1003
|
+
{insight.capabilities?.argoConfigured ? (
|
|
1004
|
+
<>
|
|
1005
|
+
Argo CD's full Git-rendered diff isn't available for this app (connection down or token not authorized).{' '}
|
|
1006
|
+
<button type="button" onClick={onOpenSettings} className="font-medium text-accent-text hover:underline">
|
|
1007
|
+
Check Argo CD in Settings
|
|
1008
|
+
</button>.
|
|
1009
|
+
</>
|
|
1010
|
+
) : (
|
|
1011
|
+
<>
|
|
1012
|
+
<button type="button" onClick={onOpenSettings} className="font-medium text-accent-text hover:underline">
|
|
1013
|
+
Connect Argo CD
|
|
1014
|
+
</button>{' '}
|
|
1015
|
+
for the full Git-rendered diff.
|
|
1016
|
+
</>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
)}
|
|
1020
|
+
{ignoredDiffs && ignoredDiffs.ruleCount > 0 && (
|
|
1021
|
+
<div className="flex items-center gap-1.5 border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
1022
|
+
<span>
|
|
1023
|
+
Some fields are intentionally excluded from drift comparison, so a resource can read as in sync
|
|
1024
|
+
even if those fields differ.
|
|
1025
|
+
</span>
|
|
1026
|
+
<Tooltip
|
|
1027
|
+
content={
|
|
1028
|
+
<div className="max-w-[46ch] space-y-1 text-left">
|
|
1029
|
+
<div>
|
|
1030
|
+
{ignoredDiffs.ruleCount} exclusion {ignoredDiffs.ruleCount === 1 ? 'rule' : 'rules'} configured
|
|
1031
|
+
{ignoredDiffs.kinds.length > 0 && <> on {ignoredDiffs.kinds.join(', ')}</>} (Argo CD{' '}
|
|
1032
|
+
<code className="inline-code">spec.ignoreDifferences</code>).
|
|
1033
|
+
</div>
|
|
1034
|
+
{ignoredDiffs.unsupportedRuleCount > 0 && (
|
|
1035
|
+
<div>
|
|
1036
|
+
{ignoredDiffs.unsupportedRuleCount} of them use jq expressions or managed-fields managers,
|
|
1037
|
+
which Radar doesn't evaluate — so a few fields Argo hides may still appear here.
|
|
1038
|
+
</div>
|
|
1039
|
+
)}
|
|
1040
|
+
</div>
|
|
1041
|
+
}
|
|
1042
|
+
delay={200}
|
|
1043
|
+
>
|
|
1044
|
+
<span className="inline-flex cursor-help items-center text-theme-text-tertiary hover:text-theme-text-secondary">
|
|
1045
|
+
<Info className="h-3 w-3" />
|
|
1046
|
+
<span className="ml-1 underline decoration-dotted underline-offset-2">details</span>
|
|
1047
|
+
</span>
|
|
1048
|
+
</Tooltip>
|
|
1049
|
+
</div>
|
|
1050
|
+
)}
|
|
948
1051
|
{totalCount === 0 ? (
|
|
949
1052
|
<div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
|
|
950
1053
|
) : displayChanges.length === 0 ? (
|
|
@@ -984,6 +1087,13 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
984
1087
|
(change.drift && change.drift.entries.length > 0) ||
|
|
985
1088
|
(change.recentEvents && change.recentEvents.length > 0)
|
|
986
1089
|
)
|
|
1090
|
+
// Change rows come from the Application's status.resources, so
|
|
1091
|
+
// they are in the Argo managed set by construction — the diff
|
|
1092
|
+
// endpoint can serve every one (a Missing resource just has an
|
|
1093
|
+
// empty live side). NOT gated on change.hasDesired/partial:
|
|
1094
|
+
// those describe Radar's LOCAL last-applied view, and the Argo
|
|
1095
|
+
// API diff exists precisely for the rows where it's absent.
|
|
1096
|
+
const canFullDiff = argoDiffAvailable && !!change.ref.kind && !!change.ref.name
|
|
987
1097
|
// Render a wave separator above this row when the wave value
|
|
988
1098
|
// changed from the previous one. waveSet=false rows under a
|
|
989
1099
|
// waved plan get a "Default wave" header — matches how Argo's
|
|
@@ -1002,10 +1112,16 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
|
|
|
1002
1112
|
change={change}
|
|
1003
1113
|
hook={hook}
|
|
1004
1114
|
explanation={explanation}
|
|
1115
|
+
syncUnavailable={syncUnavailable}
|
|
1005
1116
|
focused={focused}
|
|
1006
1117
|
autoExpand={focused}
|
|
1007
1118
|
hasInlineDetail={hasInlineDetail}
|
|
1119
|
+
canFullDiff={canFullDiff}
|
|
1120
|
+
renderResourceDiff={renderResourceDiff}
|
|
1008
1121
|
onOpenResource={onOpenResource}
|
|
1122
|
+
onSyncResource={onSyncResource}
|
|
1123
|
+
syncResourceDisabledReason={syncResourceDisabledReason}
|
|
1124
|
+
declared={declaredChanges.has(change)}
|
|
1009
1125
|
sourceTreeURL={sourceTreeURL}
|
|
1010
1126
|
registerRef={(el) => {
|
|
1011
1127
|
if (el) {
|
|
@@ -1174,10 +1290,16 @@ function ChangeRow({
|
|
|
1174
1290
|
change,
|
|
1175
1291
|
hook,
|
|
1176
1292
|
explanation,
|
|
1293
|
+
syncUnavailable,
|
|
1177
1294
|
focused,
|
|
1178
1295
|
autoExpand,
|
|
1179
1296
|
hasInlineDetail,
|
|
1297
|
+
canFullDiff,
|
|
1298
|
+
renderResourceDiff,
|
|
1180
1299
|
onOpenResource,
|
|
1300
|
+
onSyncResource,
|
|
1301
|
+
syncResourceDisabledReason,
|
|
1302
|
+
declared,
|
|
1181
1303
|
sourceTreeURL,
|
|
1182
1304
|
registerRef,
|
|
1183
1305
|
}: {
|
|
@@ -1187,10 +1309,20 @@ function ChangeRow({
|
|
|
1187
1309
|
// resources that already ran their hook.
|
|
1188
1310
|
hook: string | undefined
|
|
1189
1311
|
explanation: string
|
|
1312
|
+
// True when the parent app can't compare against Git at all (ComparisonError):
|
|
1313
|
+
// this row's Unknown sync is derivative, so render it muted instead of loud.
|
|
1314
|
+
syncUnavailable: boolean
|
|
1190
1315
|
focused: boolean
|
|
1191
1316
|
autoExpand: boolean
|
|
1192
1317
|
hasInlineDetail: boolean
|
|
1318
|
+
// When true, the expanded panel offers a "Full diff" toggle that mounts the
|
|
1319
|
+
// host-wired Argo CD diff loader for this resource.
|
|
1320
|
+
canFullDiff: boolean
|
|
1321
|
+
renderResourceDiff?: (ref: GitOpsInsightRef) => ReactNode
|
|
1193
1322
|
onOpenResource?: (ref: GitOpsChange['ref']) => void
|
|
1323
|
+
onSyncResource?: (ref: GitOpsChange['ref']) => void
|
|
1324
|
+
syncResourceDisabledReason?: string
|
|
1325
|
+
declared: boolean
|
|
1194
1326
|
// Constructed URL pointing at the source directory in the remote Git
|
|
1195
1327
|
// host (github / gitlab / bitbucket). Used as the "where this would be
|
|
1196
1328
|
// declared" affordance on Missing rows, since opening the drawer for a
|
|
@@ -1198,12 +1330,15 @@ function ChangeRow({
|
|
|
1198
1330
|
sourceTreeURL?: string | null
|
|
1199
1331
|
registerRef: (el: HTMLDivElement | null) => void
|
|
1200
1332
|
}) {
|
|
1201
|
-
|
|
1333
|
+
// A row expands when it has inline diagnostics (drift/events) or can load a
|
|
1334
|
+
// full Argo CD diff — both live in the same disclosure panel.
|
|
1335
|
+
const expandable = hasInlineDetail || canFullDiff
|
|
1336
|
+
const [expanded, setExpanded] = useState(autoExpand && expandable)
|
|
1202
1337
|
// Auto-expand when an issue alert deep-links to this row — the user just
|
|
1203
1338
|
// clicked the issue, so they want to see the detail immediately.
|
|
1204
1339
|
useEffect(() => {
|
|
1205
|
-
if (autoExpand &&
|
|
1206
|
-
}, [autoExpand,
|
|
1340
|
+
if (autoExpand && expandable) setExpanded(true)
|
|
1341
|
+
}, [autoExpand, expandable])
|
|
1207
1342
|
const driftEntries = change.drift?.entries ?? []
|
|
1208
1343
|
const events = change.recentEvents ?? []
|
|
1209
1344
|
// Missing resources have no live state to drill into — opening the drawer
|
|
@@ -1213,17 +1348,29 @@ function ChangeRow({
|
|
|
1213
1348
|
// declared source instead of a non-existent live object.
|
|
1214
1349
|
const isAbsent = change.health === 'Missing' && !change.hasLive
|
|
1215
1350
|
const handleRowClick = () => {
|
|
1216
|
-
if (
|
|
1351
|
+
if (expandable) {
|
|
1217
1352
|
setExpanded((v) => !v)
|
|
1218
1353
|
} else if (!isAbsent && onOpenResource) {
|
|
1219
1354
|
onOpenResource(change.ref)
|
|
1220
1355
|
}
|
|
1221
1356
|
}
|
|
1222
|
-
// Row stays click-affordant when there's
|
|
1357
|
+
// Row stays click-affordant when there's a disclosure panel to expand OR a
|
|
1223
1358
|
// live resource to drill into. Missing rows without inline detail are
|
|
1224
1359
|
// intentionally non-interactive — there's nowhere useful to go.
|
|
1225
|
-
const rowInteractive =
|
|
1360
|
+
const rowInteractive = expandable || (!isAbsent && !!onOpenResource)
|
|
1226
1361
|
const hookLabel = change.hookPhase || hook
|
|
1362
|
+
const canSyncResource = !!onSyncResource && isArgoResourceSyncEligible(change, declared, hookLabel)
|
|
1363
|
+
const syncResourceButton = canSyncResource ? (
|
|
1364
|
+
<button
|
|
1365
|
+
type="button"
|
|
1366
|
+
onClick={() => onSyncResource(change.ref)}
|
|
1367
|
+
disabled={!!syncResourceDisabledReason}
|
|
1368
|
+
className="inline-flex w-full items-center justify-center gap-1.5 rounded border border-theme-border bg-theme-base px-2 py-1 text-[11px] font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
1369
|
+
>
|
|
1370
|
+
<RefreshCw className="h-3 w-3" />
|
|
1371
|
+
Sync resource
|
|
1372
|
+
</button>
|
|
1373
|
+
) : null
|
|
1227
1374
|
return (
|
|
1228
1375
|
<div
|
|
1229
1376
|
ref={registerRef}
|
|
@@ -1247,7 +1394,7 @@ function ChangeRow({
|
|
|
1247
1394
|
)}
|
|
1248
1395
|
>
|
|
1249
1396
|
<div className="flex items-baseline gap-2">
|
|
1250
|
-
{
|
|
1397
|
+
{expandable ? (
|
|
1251
1398
|
<CollapseChevron open={expanded} className="h-3.5 w-3.5 self-center" />
|
|
1252
1399
|
) : (
|
|
1253
1400
|
<span aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
@@ -1297,9 +1444,22 @@ function ChangeRow({
|
|
|
1297
1444
|
<div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
|
|
1298
1445
|
)}
|
|
1299
1446
|
</button>
|
|
1300
|
-
<div className="self-start"><SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} /></div>
|
|
1301
|
-
<div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
|
|
1302
1447
|
<div className="self-start">
|
|
1448
|
+
{syncUnavailable && normalizeSyncStatus(change.sync ?? change.category) === 'Unknown' ? (
|
|
1449
|
+
<Tooltip content="Sync unavailable — Argo CD hasn't compared this app against Git yet." delay={200} wrapperClassName="inline-flex">
|
|
1450
|
+
<span className="cursor-help select-none px-1 text-theme-text-tertiary" aria-label="Sync status unavailable">—</span>
|
|
1451
|
+
</Tooltip>
|
|
1452
|
+
) : (
|
|
1453
|
+
<SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} />
|
|
1454
|
+
)}
|
|
1455
|
+
</div>
|
|
1456
|
+
<div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
|
|
1457
|
+
<div className="self-start space-y-1.5">
|
|
1458
|
+
{syncResourceButton && syncResourceDisabledReason ? (
|
|
1459
|
+
<Tooltip content={syncResourceDisabledReason} delay={200} wrapperClassName="block">
|
|
1460
|
+
{syncResourceButton}
|
|
1461
|
+
</Tooltip>
|
|
1462
|
+
) : syncResourceButton}
|
|
1303
1463
|
{/* Three affordance states:
|
|
1304
1464
|
- Live resource (not Missing): "Open <kind> <name> →" opens the
|
|
1305
1465
|
K8s drawer.
|
|
@@ -1338,11 +1498,20 @@ function ChangeRow({
|
|
|
1338
1498
|
)}
|
|
1339
1499
|
</div>
|
|
1340
1500
|
</div>
|
|
1341
|
-
{
|
|
1501
|
+
{expandable && (
|
|
1342
1502
|
<Collapse open={expanded} mountLazily>
|
|
1343
1503
|
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
|
|
1344
1504
|
{driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
|
|
1345
1505
|
{events.length > 0 && <RecentEventsPanel events={events} />}
|
|
1506
|
+
{/* Expanding a row IS the request to see its diff — render it inline
|
|
1507
|
+
rather than behind a second "Full diff" click. The capability is
|
|
1508
|
+
already gated on a live Argo connection, so this only mounts (and
|
|
1509
|
+
fetches) when the diff can actually be served. */}
|
|
1510
|
+
{canFullDiff && (
|
|
1511
|
+
<div className={clsx((driftEntries.length > 0 || events.length > 0) && 'mt-3')}>
|
|
1512
|
+
{renderResourceDiff?.(change.ref)}
|
|
1513
|
+
</div>
|
|
1514
|
+
)}
|
|
1346
1515
|
</div>
|
|
1347
1516
|
</Collapse>
|
|
1348
1517
|
)}
|
|
@@ -1355,6 +1524,7 @@ function ChangeRow({
|
|
|
1355
1524
|
// inline as "old → new". Path is monospace; values are JSON-encoded and
|
|
1356
1525
|
// pre-wrapped so structured values (objects, arrays) render readably.
|
|
1357
1526
|
function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
1527
|
+
const sourceLabel = driftSourceLabel(drift.source)
|
|
1358
1528
|
return (
|
|
1359
1529
|
<div>
|
|
1360
1530
|
<div className="mb-2 flex items-baseline justify-between gap-2">
|
|
@@ -1362,6 +1532,7 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
|
1362
1532
|
<span className="text-[10px] text-theme-text-tertiary">
|
|
1363
1533
|
desired (Git) → live ·
|
|
1364
1534
|
{drift.truncated ? ' showing first 50 entries' : ` ${drift.entries.length} field${drift.entries.length === 1 ? '' : 's'}`}
|
|
1535
|
+
{sourceLabel && ` · ${sourceLabel}`}
|
|
1365
1536
|
</span>
|
|
1366
1537
|
</div>
|
|
1367
1538
|
<div className="space-y-1 font-mono text-[11px]">
|
|
@@ -1373,6 +1544,15 @@ function DriftPanel({ drift }: { drift: NonNullable<GitOpsChange['drift']> }) {
|
|
|
1373
1544
|
)
|
|
1374
1545
|
}
|
|
1375
1546
|
|
|
1547
|
+
// Provenance label for a Drift's field entries. Only Argo-sourced drift gets an
|
|
1548
|
+
// explicit label; the built-in last-applied path renders without one (the
|
|
1549
|
+
// "desired (Git) → live" copy already implies it). Unknown sources (a library
|
|
1550
|
+
// consumer lagging the backend) fall through to no label rather than surfacing
|
|
1551
|
+
// a raw enum string.
|
|
1552
|
+
function driftSourceLabel(source: NonNullable<GitOpsChange['drift']>['source']): string {
|
|
1553
|
+
return source === 'argocd-api' ? 'via Argo CD API' : ''
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1376
1556
|
function DriftEntryRow({ entry }: { entry: NonNullable<GitOpsChange['drift']>['entries'][number] }) {
|
|
1377
1557
|
if (entry.op === 'removed') {
|
|
1378
1558
|
return (
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
entryTone,
|
|
9
9
|
gitopsToSeverity,
|
|
10
10
|
healthStatusRank,
|
|
11
|
+
isArgoResourceSyncEligible,
|
|
11
12
|
messageToPhase,
|
|
12
13
|
phaseToTone,
|
|
13
14
|
resourceStatusCounts,
|
|
@@ -171,6 +172,21 @@ describe('resourceStatusCounts', () => {
|
|
|
171
172
|
})
|
|
172
173
|
})
|
|
173
174
|
|
|
175
|
+
describe('isArgoResourceSyncEligible', () => {
|
|
176
|
+
const outOfSync = change({ ref: { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }, sync: 'OutOfSync' })
|
|
177
|
+
|
|
178
|
+
it('allows declared OutOfSync and Missing resources', () => {
|
|
179
|
+
expect(isArgoResourceSyncEligible(outOfSync, true)).toBe(true)
|
|
180
|
+
expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'missing' }, health: 'Missing' }), true)).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('excludes generated, hook, and already-synced resources', () => {
|
|
184
|
+
expect(isArgoResourceSyncEligible(outOfSync, false)).toBe(false)
|
|
185
|
+
expect(isArgoResourceSyncEligible(outOfSync, true, 'PreSync')).toBe(false)
|
|
186
|
+
expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'ready' }, sync: 'Synced', health: 'Healthy' }), true)).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
174
190
|
describe('syncStatusRank / healthStatusRank', () => {
|
|
175
191
|
it('ascending sync rank surfaces OutOfSync before Synced', () => {
|
|
176
192
|
expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
|
|
@@ -46,6 +46,11 @@ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
|
|
|
46
46
|
return normalizeHealthStatus(change.health)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
|
|
50
|
+
if (!declared || hook || change.hookPhase) return false
|
|
51
|
+
return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
// Rank sync/health so an ascending sort surfaces the states an operator cares
|
|
50
55
|
// about first (drift, then failures). Mirrors the GitOps application table's
|
|
51
56
|
// syncRank/healthRank so the two tables order status the same way.
|
|
@@ -658,6 +658,11 @@ function isNodeTerminating(node: GitOpsTreeNode): boolean {
|
|
|
658
658
|
// treatment around them (tool icon overlay, "→" affordance, stronger
|
|
659
659
|
// border) advertises that they're not just leaves.
|
|
660
660
|
function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
|
|
661
|
+
// The root node IS the resource this detail page is already showing — it is
|
|
662
|
+
// the subject, not a portal to another page. Never give it the portal
|
|
663
|
+
// treatment (tool badge, chevron, click-to-dive ring): clicking the thing
|
|
664
|
+
// you're already viewing would just stack an identical page onto itself.
|
|
665
|
+
if (node.role === 'root') return undefined
|
|
661
666
|
const t = node.data?.gitopsTool
|
|
662
667
|
if (t === 'argocd' || t === 'fluxcd') return t
|
|
663
668
|
return undefined
|
|
@@ -132,6 +132,10 @@ export interface IssueRowProps {
|
|
|
132
132
|
as?: 'li' | 'div';
|
|
133
133
|
className?: string;
|
|
134
134
|
dimmed?: boolean;
|
|
135
|
+
/** Suppress the "Subject" deep-link in the expanded body — set by hosts that
|
|
136
|
+
* embed the row under the very resource that IS the subject (the drawer header
|
|
137
|
+
* already names it), so it doesn't echo back redundantly. */
|
|
138
|
+
hideSubject?: boolean;
|
|
135
139
|
renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
136
140
|
renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
137
141
|
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
@@ -148,6 +152,7 @@ export function IssueRow({
|
|
|
148
152
|
as = 'li',
|
|
149
153
|
className,
|
|
150
154
|
dimmed,
|
|
155
|
+
hideSubject,
|
|
151
156
|
renderBadges,
|
|
152
157
|
renderMeta,
|
|
153
158
|
renderActions,
|
|
@@ -163,6 +168,36 @@ export function IssueRow({
|
|
|
163
168
|
const slotCtx = { issue, open };
|
|
164
169
|
const timing = issueTiming(issue);
|
|
165
170
|
|
|
171
|
+
// Severity pill + age + timing chip. Rendered in two positions the container
|
|
172
|
+
// query toggles: inline at the row's right edge on a wide container, and on a
|
|
173
|
+
// line of its own below the resource line once the row is too narrow to hold
|
|
174
|
+
// it beside the title — so the title never has to truncate to make room.
|
|
175
|
+
const metaChips = (wrapperClass: string) => (
|
|
176
|
+
<div className={`items-center gap-3 ${wrapperClass}`}>
|
|
177
|
+
<span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
|
|
178
|
+
{ISSUE_SEVERITY_LABEL[severity]}
|
|
179
|
+
</span>
|
|
180
|
+
{issue.first_seen ? (
|
|
181
|
+
<>
|
|
182
|
+
<Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
|
|
183
|
+
<time
|
|
184
|
+
dateTime={issue.first_seen}
|
|
185
|
+
className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
186
|
+
>
|
|
187
|
+
<Clock className="h-3 w-3" aria-hidden />
|
|
188
|
+
{formatCompactAge(issue.first_seen)}
|
|
189
|
+
</time>
|
|
190
|
+
</Tooltip>
|
|
191
|
+
{timing ? (
|
|
192
|
+
<Tooltip content={timing.tooltip} delay={200}>
|
|
193
|
+
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
194
|
+
</Tooltip>
|
|
195
|
+
) : null}
|
|
196
|
+
</>
|
|
197
|
+
) : null}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
|
|
166
201
|
useEffect(() => {
|
|
167
202
|
if (open) {
|
|
168
203
|
setRenderDetails(true);
|
|
@@ -206,13 +241,13 @@ export function IssueRow({
|
|
|
206
241
|
onToggle();
|
|
207
242
|
}
|
|
208
243
|
}}
|
|
209
|
-
className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
244
|
+
className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
210
245
|
>
|
|
211
246
|
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
212
247
|
|
|
213
248
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
214
249
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
215
|
-
<span className="
|
|
250
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
216
251
|
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
217
252
|
{renderBadges?.(slotCtx)}
|
|
218
253
|
{/* The detector reason rides the title row while COLLAPSED so the
|
|
@@ -265,32 +300,16 @@ export function IssueRow({
|
|
|
265
300
|
) : null}
|
|
266
301
|
{renderMeta?.(slotCtx)}
|
|
267
302
|
</div>
|
|
303
|
+
{/* Narrow container: the meta chips drop to their own line here rather
|
|
304
|
+
than stealing width from the title on the row above. Hidden once the
|
|
305
|
+
row is wide enough to hold them inline in the right cluster. */}
|
|
306
|
+
{metaChips('flex @2xl/issue:hidden')}
|
|
268
307
|
</div>
|
|
269
308
|
|
|
270
|
-
{/* Right cluster —
|
|
309
|
+
{/* Right cluster — the always-visible affordances (per-row action +
|
|
310
|
+
chevron) plus, on a wide container, the inline meta chips. */}
|
|
271
311
|
<div className="flex shrink-0 items-center gap-3">
|
|
272
|
-
|
|
273
|
-
{ISSUE_SEVERITY_LABEL[severity]}
|
|
274
|
-
</span>
|
|
275
|
-
{/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
|
|
276
|
-
{issue.first_seen ? (
|
|
277
|
-
<>
|
|
278
|
-
<Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
|
|
279
|
-
<time
|
|
280
|
-
dateTime={issue.first_seen}
|
|
281
|
-
className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
282
|
-
>
|
|
283
|
-
<Clock className="h-3 w-3" aria-hidden />
|
|
284
|
-
{formatCompactAge(issue.first_seen)}
|
|
285
|
-
</time>
|
|
286
|
-
</Tooltip>
|
|
287
|
-
{timing ? (
|
|
288
|
-
<Tooltip content={timing.tooltip} delay={200}>
|
|
289
|
-
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
290
|
-
</Tooltip>
|
|
291
|
-
) : null}
|
|
292
|
-
</>
|
|
293
|
-
) : null}
|
|
312
|
+
{metaChips('hidden @2xl/issue:flex')}
|
|
294
313
|
{renderActions?.(slotCtx)}
|
|
295
314
|
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
296
315
|
</div>
|
|
@@ -329,7 +348,7 @@ export function IssueRow({
|
|
|
329
348
|
</section>
|
|
330
349
|
) : null}
|
|
331
350
|
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
332
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
351
|
+
<AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
333
352
|
</div>
|
|
334
353
|
</div>
|
|
335
354
|
</div>
|
|
@@ -519,11 +538,13 @@ function ageTitle(issue: Issue): string {
|
|
|
519
538
|
|
|
520
539
|
function AffectedResources({
|
|
521
540
|
issue,
|
|
541
|
+
hideSubject,
|
|
522
542
|
resourceHref,
|
|
523
543
|
onResourceClick,
|
|
524
544
|
ResourceLinkIcon,
|
|
525
545
|
}: {
|
|
526
546
|
issue: Issue;
|
|
547
|
+
hideSubject?: boolean;
|
|
527
548
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
528
549
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
529
550
|
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
@@ -532,14 +553,19 @@ function AffectedResources({
|
|
|
532
553
|
// count is the backend fan-out size (members, subject excluded — see
|
|
533
554
|
// grouping.go); fall back to the inline member count, not +1.
|
|
534
555
|
const total = issue.count ?? members.length;
|
|
556
|
+
// With the subject suppressed and no fanned-out members, this section has
|
|
557
|
+
// nothing to show — return null so the divider row above it doesn't render empty.
|
|
558
|
+
if (hideSubject && members.length === 0) return null;
|
|
535
559
|
return (
|
|
536
560
|
<section className="flex flex-col gap-3">
|
|
537
561
|
{/* The subject (the grouped thing — e.g. the Deployment) is always the
|
|
538
562
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
539
563
|
an <li>, so it needs a list parent of its own. */}
|
|
540
|
-
|
|
541
|
-
<
|
|
542
|
-
|
|
564
|
+
{!hideSubject && (
|
|
565
|
+
<ul className="flex flex-col gap-px">
|
|
566
|
+
<ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
567
|
+
</ul>
|
|
568
|
+
)}
|
|
543
569
|
{members.length > 0 && (
|
|
544
570
|
<CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
|
|
545
571
|
<ul className="flex flex-col gap-px">
|