@skyhook-io/k8s-ui 1.18.0 → 1.18.1
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/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +66 -2
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -32,6 +32,9 @@ import { getDefaultContainerName, getExecContainerNames } from '../resources/res
|
|
|
32
32
|
import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
|
|
33
33
|
import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
|
|
34
34
|
import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
|
|
35
|
+
import { getGitOpsTool } from '../gitops/detail-helpers'
|
|
36
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
37
|
+
import { isApiGroup } from '../resources/resource-utils-cnpg'
|
|
35
38
|
import { isArgoRolloutResource } from '../../utils/workload-rollout'
|
|
36
39
|
import { isCoreBatchJob } from '../../utils/api-resources'
|
|
37
40
|
import { formatRelativeAgeTime } from '../../utils/format'
|
|
@@ -40,6 +43,8 @@ import { formatRelativeAgeTime } from '../../utils/format'
|
|
|
40
43
|
// ACTIONS BAR - Interactive buttons that change based on resource kind
|
|
41
44
|
// ============================================================================
|
|
42
45
|
|
|
46
|
+
const FLUX_ACTION_KINDS = new Set(['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'])
|
|
47
|
+
|
|
43
48
|
interface ResourceActionsBarProps {
|
|
44
49
|
resource: SelectedResource
|
|
45
50
|
leadingActions?: React.ReactNode
|
|
@@ -187,6 +192,7 @@ export function ResourceActionsBar({
|
|
|
187
192
|
onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
|
|
188
193
|
}: ResourceActionsBarProps) {
|
|
189
194
|
const kind = resource.kind.toLowerCase()
|
|
195
|
+
const isFluxAction = FLUX_ACTION_KINDS.has(kind) && getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
|
|
190
196
|
const coreBatchJob = isCoreBatchJob(kind, resource.group)
|
|
191
197
|
const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
|
|
192
198
|
(kind === 'rollouts' && isArgoRolloutResource(data))
|
|
@@ -406,7 +412,7 @@ export function ResourceActionsBar({
|
|
|
406
412
|
)}
|
|
407
413
|
|
|
408
414
|
{/* Service actions */}
|
|
409
|
-
{kind === 'services' &&
|
|
415
|
+
{kind === 'services' && data?.apiVersion === 'v1' && canPortForward && resource.namespace && resource.name && renderPortForward && (
|
|
410
416
|
renderPortForward({
|
|
411
417
|
type: 'service',
|
|
412
418
|
namespace: resource.namespace,
|
|
@@ -516,7 +522,7 @@ export function ResourceActionsBar({
|
|
|
516
522
|
)}
|
|
517
523
|
|
|
518
524
|
{/* FluxCD actions */}
|
|
519
|
-
{
|
|
525
|
+
{isFluxAction && (
|
|
520
526
|
<FluxActions
|
|
521
527
|
resource={resource}
|
|
522
528
|
data={data}
|
|
@@ -532,7 +538,7 @@ export function ResourceActionsBar({
|
|
|
532
538
|
)}
|
|
533
539
|
|
|
534
540
|
{/* ArgoCD actions */}
|
|
535
|
-
{kind === 'applications' && (
|
|
541
|
+
{kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && (
|
|
536
542
|
<ArgoActions
|
|
537
543
|
resource={resource}
|
|
538
544
|
data={data}
|
|
@@ -1087,3 +1087,52 @@ it('uses RayJob native detail only for the supported exact API', () => {
|
|
|
1087
1087
|
if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
|
|
1088
1088
|
}
|
|
1089
1089
|
})
|
|
1090
|
+
|
|
1091
|
+
describe('API group identity', () => {
|
|
1092
|
+
it.each([
|
|
1093
|
+
['applications', 'app.k8s.io/v1beta1', 'Application'],
|
|
1094
|
+
['applications', 'notargoproj.io/v1alpha1', 'Application'],
|
|
1095
|
+
['services', 'vendor.serving.knative.dev/v1', 'Service'],
|
|
1096
|
+
['ingresses', 'vendor.networking.internal.knative.dev/v1alpha1', 'Ingress'],
|
|
1097
|
+
['certificates', 'vendor.networking.internal.knative.dev/v1alpha1', 'Certificate'],
|
|
1098
|
+
['gateways', 'vendor.networking.istio.io/v1', 'Gateway'],
|
|
1099
|
+
])('keeps %s in %s generic', (plural, apiVersion, kind) => {
|
|
1100
|
+
const data = { apiVersion, kind, metadata: { name: 'foreign', namespace: 'test' }, spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' } }
|
|
1101
|
+
expect(renderKind(plural, data, 'test')).toContain('custom-value')
|
|
1102
|
+
expect(getResourceStatus(plural, data)?.text).toBe('VendorReady')
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it('retains Argo CD status for its exact Application group', () => {
|
|
1106
|
+
const data = { apiVersion: 'argoproj.io/v1alpha1', kind: 'Application', metadata: { name: 'real' }, status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } }
|
|
1107
|
+
expect(renderKind('applications', data)).toContain('Synced')
|
|
1108
|
+
expect(getResourceStatus('applications', data)?.text).toBe('Healthy')
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
it('retains Knative Service status and core Service presentation', () => {
|
|
1112
|
+
expect(getResourceStatus('services', { apiVersion: 'serving.knative.dev/v1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.text).toBe('Ready')
|
|
1113
|
+
expect(renderKind('services', { apiVersion: 'v1', metadata: { name: 'core' }, spec: { type: 'ClusterIP', clusterIP: '10.0.0.1' } })).toContain('10.0.0.1')
|
|
1114
|
+
})
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
describe('Flux kind collisions', () => {
|
|
1118
|
+
it.each([
|
|
1119
|
+
['gitrepositories', 'source.toolkit.fluxcd.io'],
|
|
1120
|
+
['ocirepositories', 'source.toolkit.fluxcd.io'],
|
|
1121
|
+
['helmrepositories', 'source.toolkit.fluxcd.io'],
|
|
1122
|
+
['kustomizations', 'kustomize.toolkit.fluxcd.io'],
|
|
1123
|
+
['helmreleases', 'helm.toolkit.fluxcd.io'],
|
|
1124
|
+
['alerts', 'notification.toolkit.fluxcd.io'],
|
|
1125
|
+
])('keeps foreign %s generic and preserves its real Flux renderer', (kind, group) => {
|
|
1126
|
+
const resource = {
|
|
1127
|
+
apiVersion: `${group}/v1`, metadata: { name: 'example', namespace: 'test' },
|
|
1128
|
+
spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' },
|
|
1129
|
+
}
|
|
1130
|
+
expect(renderKind(kind, resource, 'test')).not.toContain('custom-value')
|
|
1131
|
+
expect(getResourceStatus(kind, resource)?.text).not.toBe('VendorReady')
|
|
1132
|
+
for (const apiVersion of ['vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
|
|
1133
|
+
const foreign = { ...resource, apiVersion }
|
|
1134
|
+
expect(renderKind(kind, foreign, 'test')).toContain('custom-value')
|
|
1135
|
+
expect(getResourceStatus(kind, foreign)?.text).toBe('VendorReady')
|
|
1136
|
+
}
|
|
1137
|
+
})
|
|
1138
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getGitOpsTool } from '../gitops/detail-helpers'
|
|
2
|
+
import { apiVersionToGroup } from '../../utils/navigation'
|
|
1
3
|
import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
|
|
2
4
|
import { clsx } from 'clsx'
|
|
3
5
|
import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
|
|
@@ -552,6 +554,8 @@ interface ResourceRendererDispatchProps {
|
|
|
552
554
|
resource: SelectedResource
|
|
553
555
|
data: any
|
|
554
556
|
relationships?: Relationships
|
|
557
|
+
/** See RelatedResourcesSection. */
|
|
558
|
+
trackedBy?: string
|
|
555
559
|
certificateInfo?: SecretCertificateInfo
|
|
556
560
|
hpaDiagnosis?: HPADiagnosis
|
|
557
561
|
scalerDiagnostics?: ScalerDiagnosis[]
|
|
@@ -598,6 +602,7 @@ export function ResourceRendererDispatch({
|
|
|
598
602
|
resource,
|
|
599
603
|
data,
|
|
600
604
|
relationships,
|
|
605
|
+
trackedBy,
|
|
601
606
|
certificateInfo,
|
|
602
607
|
hpaDiagnosis,
|
|
603
608
|
scalerDiagnostics,
|
|
@@ -701,8 +706,11 @@ export function ResourceRendererDispatch({
|
|
|
701
706
|
// foreign CRD render a BLANK drawer rather than the generic one.
|
|
702
707
|
// CAPI's `machines` and `machinesets` need the same protection because another
|
|
703
708
|
// API group can publish either plural without inheriting CAPI presentation.
|
|
709
|
+
const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
|
|
710
|
+
const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
|
|
704
711
|
const isGroupGatedKind =
|
|
705
|
-
kind === '
|
|
712
|
+
isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
|
|
713
|
+
|| kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
|
|
706
714
|
|| kind === 'objectstores' || kind === 'databases' || kind === 'publications'
|
|
707
715
|
|| kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'databaseroles' || kind === 'jobsets'
|
|
708
716
|
|| kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
|
|
@@ -710,7 +718,13 @@ export function ResourceRendererDispatch({
|
|
|
710
718
|
|| kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
|
|
711
719
|
const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
|
|
712
720
|
const groupGatedMatched =
|
|
713
|
-
(
|
|
721
|
+
(isFluxKind && isFluxResource)
|
|
722
|
+
|| (kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io'))
|
|
723
|
+
|| (kind === 'services' && (data?.apiVersion === 'v1' || isApiGroup(data?.apiVersion, 'serving.knative.dev')))
|
|
724
|
+
|| (kind === 'ingresses' && ['networking.k8s.io', 'extensions', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
|
|
725
|
+
|| (kind === 'certificates' && ['cert-manager.io', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
|
|
726
|
+
|| (kind === 'gateways' && ['gateway.networking.k8s.io', 'networking.istio.io'].some(group => isApiGroup(data?.apiVersion, group)))
|
|
727
|
+
|| (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
|
|
714
728
|
|| (kind === 'backups' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'velero.io')))
|
|
715
729
|
|| ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
|
|
716
730
|
|| (kind === 'objectstores' && isApiGroup(data?.apiVersion, 'barmancloud.cnpg.io'))
|
|
@@ -799,7 +813,7 @@ export function ResourceRendererDispatch({
|
|
|
799
813
|
|
|
800
814
|
const sidebarContent = showCommonSections && (
|
|
801
815
|
<>
|
|
802
|
-
<RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
|
|
816
|
+
<RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} kind={kind} trackedBy={trackedBy} />
|
|
803
817
|
{kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
|
|
804
818
|
<LabelsSection data={data} />
|
|
805
819
|
<AnnotationsSection data={data} />
|
|
@@ -824,9 +838,9 @@ export function ResourceRendererDispatch({
|
|
|
824
838
|
/>
|
|
825
839
|
)}
|
|
826
840
|
{kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
|
|
827
|
-
{kind === 'services' &&
|
|
841
|
+
{kind === 'services' && data?.apiVersion === 'v1' && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
|
|
828
842
|
{kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
|
|
829
|
-
{kind === 'ingresses' &&
|
|
843
|
+
{kind === 'ingresses' && (isApiGroup(data?.apiVersion, 'networking.k8s.io') || isApiGroup(data?.apiVersion, 'extensions')) && <IngressRenderer data={data} onNavigate={onNavigate} />}
|
|
830
844
|
{kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
|
|
831
845
|
{kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
|
|
832
846
|
{kind === 'jobs' && !nonCoreJobFallthrough && <JobComp data={data} onNavigate={onNavigate} />}
|
|
@@ -848,7 +862,7 @@ export function ResourceRendererDispatch({
|
|
|
848
862
|
{kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
|
|
849
863
|
{(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
|
|
850
864
|
{kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
|
|
851
|
-
{kind === 'certificates' &&
|
|
865
|
+
{kind === 'certificates' && isApiGroup(data?.apiVersion, 'cert-manager.io') && <CertificateRenderer data={data} />}
|
|
852
866
|
{kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
|
|
853
867
|
{kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
|
|
854
868
|
{kind === 'storageclasses' && <StorageClassRenderer data={data} />}
|
|
@@ -857,7 +871,7 @@ export function ResourceRendererDispatch({
|
|
|
857
871
|
{kind === 'issuers' && <IssuerRenderer data={data} />}
|
|
858
872
|
{kind === 'orders' && <OrderRenderer data={data} />}
|
|
859
873
|
{kind === 'challenges' && <ChallengeRenderer data={data} />}
|
|
860
|
-
{kind === 'gateways' && (data.apiVersion
|
|
874
|
+
{kind === 'gateways' && groupGatedMatched && (isApiGroup(data.apiVersion, 'networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
|
|
861
875
|
{kind === 'gatewayclasses' && <GatewayClassRenderer data={data} />}
|
|
862
876
|
{kind === 'httproutes' && <HTTPRouteRenderer data={data} onNavigate={onNavigate} />}
|
|
863
877
|
{kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -877,13 +891,13 @@ export function ResourceRendererDispatch({
|
|
|
877
891
|
{(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
|
|
878
892
|
{(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
|
|
879
893
|
{kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
|
|
880
|
-
{kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
|
|
881
|
-
{kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
|
|
882
|
-
{kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
|
|
883
|
-
{kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
|
|
884
|
-
{kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
|
|
885
|
-
{kind === 'alerts' && <AlertRenderer data={data} />}
|
|
886
|
-
{kind === 'applications' && <ArgoApplicationRenderer data={data} />}
|
|
894
|
+
{kind === 'gitrepositories' && isFluxResource && <GitRepositoryRenderer data={data} />}
|
|
895
|
+
{kind === 'ocirepositories' && isFluxResource && <OCIRepositoryRenderer data={data} />}
|
|
896
|
+
{kind === 'helmrepositories' && isFluxResource && <HelmRepositoryRenderer data={data} />}
|
|
897
|
+
{kind === 'kustomizations' && isFluxResource && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
|
|
898
|
+
{kind === 'helmreleases' && isFluxResource && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
|
|
899
|
+
{kind === 'alerts' && isFluxResource && <AlertRenderer data={data} />}
|
|
900
|
+
{kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ArgoApplicationRenderer data={data} />}
|
|
887
901
|
{kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
|
|
888
902
|
{kind === 'nodeclaims' && <KarpenterNodeClaimRenderer data={data} onNavigate={onNavigate} />}
|
|
889
903
|
{kind === 'ec2nodeclasses' && <KarpenterEC2NodeClassRenderer data={data} />}
|
|
@@ -989,14 +1003,14 @@ export function ResourceRendererDispatch({
|
|
|
989
1003
|
{kind === 'leases' && <LeaseRenderer data={data} />}
|
|
990
1004
|
{kind === 'limitranges' && <LimitRangeRenderer data={data} />}
|
|
991
1005
|
{/* Knative Serving */}
|
|
992
|
-
{(kind === 'services' && data?.apiVersion
|
|
1006
|
+
{(kind === 'services' && isApiGroup(data?.apiVersion, 'serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
993
1007
|
{kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
994
1008
|
{kind === 'knativeconfigurations' && <KnativeConfigurationRenderer data={data} onNavigate={onNavigate} />}
|
|
995
1009
|
{kind === 'knativerevisions' && <KnativeRevisionRenderer data={data} onNavigate={onNavigate} />}
|
|
996
1010
|
{kind === 'knativeroutes' && <KnativeRouteRenderer data={data} onNavigate={onNavigate} />}
|
|
997
|
-
{(kind === 'ingresses' && data?.apiVersion
|
|
1011
|
+
{(kind === 'ingresses' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
|
|
998
1012
|
{kind === 'knativeingresses' && <KnativeIngressRenderer data={data} />}
|
|
999
|
-
{(kind === 'certificates' && data?.apiVersion
|
|
1013
|
+
{(kind === 'certificates' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
|
|
1000
1014
|
{kind === 'knativecertificates' && <KnativeCertificateRenderer data={data} />}
|
|
1001
1015
|
{kind === 'serverlessservices' && <ServerlessServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
1002
1016
|
{kind === 'domainmappings' && <DomainMappingRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -1101,11 +1115,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1101
1115
|
}
|
|
1102
1116
|
if (k === 'replicasets') return getWorkloadStatus(data, k)
|
|
1103
1117
|
if (k === 'services') {
|
|
1104
|
-
if (data.apiVersion
|
|
1118
|
+
if (isApiGroup(data.apiVersion, 'serving.knative.dev')) {
|
|
1105
1119
|
const status = getKnativeConditionStatus(data)
|
|
1106
1120
|
return { text: status.text, color: status.color }
|
|
1107
1121
|
}
|
|
1108
|
-
return getServiceStatus(data)
|
|
1122
|
+
if (data.apiVersion === 'v1') return getServiceStatus(data)
|
|
1109
1123
|
}
|
|
1110
1124
|
if (k === 'endpointslices') {
|
|
1111
1125
|
const endpoints = data.endpoints || []
|
|
@@ -1178,11 +1192,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1178
1192
|
if (k === 'workflows') return getWorkflowStatus(data)
|
|
1179
1193
|
if (k === 'cronworkflows') return getCronWorkflowStatus(data)
|
|
1180
1194
|
if (k === 'certificates') {
|
|
1181
|
-
if (data.apiVersion
|
|
1195
|
+
if (isApiGroup(data.apiVersion, 'networking.internal.knative.dev')) {
|
|
1182
1196
|
const status = getKnativeConditionStatus(data)
|
|
1183
1197
|
return { text: status.text, color: status.color }
|
|
1184
1198
|
}
|
|
1185
|
-
return getCertificateStatus(data)
|
|
1199
|
+
if (isApiGroup(data.apiVersion, 'cert-manager.io')) return getCertificateStatus(data)
|
|
1186
1200
|
}
|
|
1187
1201
|
if (k === 'persistentvolumes') return getPVStatus(data)
|
|
1188
1202
|
if (k === 'certificaterequests') return getCertificateRequestStatus(data)
|
|
@@ -1191,20 +1205,21 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1191
1205
|
if (k === 'orders') return getOrderState(data)
|
|
1192
1206
|
if (k === 'challenges') return getChallengeState(data)
|
|
1193
1207
|
if (k === 'gateways') {
|
|
1194
|
-
if (data.apiVersion
|
|
1195
|
-
return getGatewayStatus(data)
|
|
1208
|
+
if (isApiGroup(data.apiVersion, 'networking.istio.io')) return getIstioGatewayStatus(data)
|
|
1209
|
+
if (isApiGroup(data.apiVersion, 'gateway.networking.k8s.io')) return getGatewayStatus(data)
|
|
1196
1210
|
}
|
|
1197
1211
|
if (k === 'gatewayclasses') return getGatewayClassStatus(data)
|
|
1198
1212
|
if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes' || k === 'udproutes') return getRouteStatus(data)
|
|
1199
1213
|
if (k === 'sealedsecrets') return getSealedSecretStatus(data)
|
|
1200
1214
|
if (k === 'poddisruptionbudgets') return getPDBStatus(data)
|
|
1201
|
-
|
|
1202
|
-
if (k === '
|
|
1203
|
-
if (k === '
|
|
1204
|
-
if (k === '
|
|
1205
|
-
if (k === '
|
|
1206
|
-
if (k === '
|
|
1207
|
-
if (k === '
|
|
1215
|
+
const isFluxResource = getGitOpsTool(k, apiVersionToGroup(data.apiVersion)) === 'flux'
|
|
1216
|
+
if (k === 'gitrepositories' && isFluxResource) return getGitRepositoryStatus(data)
|
|
1217
|
+
if (k === 'ocirepositories' && isFluxResource) return getOCIRepositoryStatus(data)
|
|
1218
|
+
if (k === 'helmrepositories' && isFluxResource) return getHelmRepositoryStatus(data)
|
|
1219
|
+
if (k === 'kustomizations' && isFluxResource) return getKustomizationStatus(data)
|
|
1220
|
+
if (k === 'helmreleases' && isFluxResource) return getFluxHelmReleaseStatus(data)
|
|
1221
|
+
if (k === 'alerts' && isFluxResource) return getFluxAlertStatus(data)
|
|
1222
|
+
if (k === 'applications' && isApiGroup(data.apiVersion, 'argoproj.io')) return getArgoApplicationStatus(data)
|
|
1208
1223
|
if (k === 'nodepools') return getNodePoolStatus(data)
|
|
1209
1224
|
if (k === 'nodeclaims') return getNodeClaimStatus(data)
|
|
1210
1225
|
if (k === 'ec2nodeclasses') return getEC2NodeClassStatus(data)
|
|
@@ -1349,7 +1364,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1349
1364
|
if (
|
|
1350
1365
|
knativeConditionKinds.includes(k)
|
|
1351
1366
|
|| isKnativeSubscription
|
|
1352
|
-
|| (k === 'ingresses' && data.apiVersion
|
|
1367
|
+
|| (k === 'ingresses' && isApiGroup(data.apiVersion, 'networking.internal.knative.dev'))
|
|
1353
1368
|
) {
|
|
1354
1369
|
const status = getKnativeConditionStatus(data)
|
|
1355
1370
|
return { text: status.text, color: status.color }
|
|
@@ -216,6 +216,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
216
216
|
const singleEvents: TimelineEvent[] = []
|
|
217
217
|
|
|
218
218
|
for (const item of items) {
|
|
219
|
+
if (item.id === selectedEventId) {
|
|
220
|
+
singleEvents.push(item)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
219
223
|
if (foldPerResource?.(item)) {
|
|
220
224
|
const key = `fold:${item.kind}:${item.namespace}:${item.name}`
|
|
221
225
|
const existing = groups.get(key) || []
|
|
@@ -344,7 +348,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
344
348
|
if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
|
|
345
349
|
|
|
346
350
|
return groups
|
|
347
|
-
}, [filteredActivity, compact, foldPerResource])
|
|
351
|
+
}, [filteredActivity, compact, foldPerResource, selectedEventId])
|
|
348
352
|
|
|
349
353
|
// Visible-window reporting: rows carry their time span in data attributes;
|
|
350
354
|
// on scroll (rAF-throttled) and whenever the row set changes, the span of
|
|
@@ -54,11 +54,12 @@ describe('FetchResult', () => {
|
|
|
54
54
|
expect(html).toContain('Resource cache not available')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// kind_sync_failed is how Radar releases before kind_sync_timed_out spelled it.
|
|
58
|
+
it.each(['kind_sync_timed_out', 'kind_sync_failed'])('does not blame the cluster for a %s 503', (code) => {
|
|
58
59
|
const html = renderToString(
|
|
59
60
|
<FetchResult
|
|
60
61
|
loading={false}
|
|
61
|
-
error={shaped('pods failed to load within the sync deadline', 503, { error_code:
|
|
62
|
+
error={shaped('pods failed to load within the sync deadline', 503, { error_code: code })}
|
|
62
63
|
/>,
|
|
63
64
|
)
|
|
64
65
|
expect(html).not.toContain('Cluster unavailable')
|
|
@@ -66,6 +67,22 @@ describe('FetchResult', () => {
|
|
|
66
67
|
expect(html).toContain('pods failed to load within the sync deadline')
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
it('blames Radar\'s collector, not the cluster, for a collector failure', () => {
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
<FetchResult loading={false} error={shaped('Radar is not allowed to list pods', 503, { error_code: 'radar_forbidden' })} />,
|
|
73
|
+
)
|
|
74
|
+
expect(html).toContain('Radar can')
|
|
75
|
+
expect(html).not.toContain('Cluster unavailable')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps a disconnected cluster reading as unavailable', () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
<FetchResult loading={false} error={shaped('Not connected to cluster', 503, { error_code: 'cluster_disconnected' })} />,
|
|
81
|
+
)
|
|
82
|
+
expect(html).toContain('Cluster unavailable')
|
|
83
|
+
expect(html).not.toContain('Radar can')
|
|
84
|
+
})
|
|
85
|
+
|
|
69
86
|
it('renders a Retry button only when onRetry is given', () => {
|
|
70
87
|
const err = shaped('internal server error', 500)
|
|
71
88
|
expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
|
|
@@ -3,6 +3,7 @@ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, t
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { PaneLoader } from './PaneLoader'
|
|
5
5
|
import { isFetchError } from '../../types/fetch-error'
|
|
6
|
+
import { readActionFor, canonicalReadCode } from '../../utils/read-actions'
|
|
6
7
|
|
|
7
8
|
// FetchResult collapses (loading, error, no-data) into one rendered outcome:
|
|
8
9
|
// loader while loading, a typed error surface when the fetch threw, or the
|
|
@@ -15,6 +16,7 @@ import { isFetchError } from '../../types/fetch-error'
|
|
|
15
16
|
// error (403) → "Access denied" + error.message
|
|
16
17
|
// error (404) → notFoundMessage + error.message
|
|
17
18
|
// error (503 sync failed) → "Couldn't load this view" + error.message
|
|
19
|
+
// error (503 collector) → "Radar can't read this" + error.message
|
|
18
20
|
// error (503) → "Cluster unavailable" + error.message
|
|
19
21
|
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
20
22
|
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
@@ -118,9 +120,19 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
118
120
|
case 503:
|
|
119
121
|
// The cluster is connected; one kind's informer missed its sync
|
|
120
122
|
// deadline, so "Cluster unavailable" would send the reader the wrong way.
|
|
121
|
-
if (errorCodeOf(error) === '
|
|
123
|
+
if (canonicalReadCode(errorCodeOf(error)) === 'kind_sync_timed_out') {
|
|
122
124
|
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
123
125
|
}
|
|
126
|
+
// Shares the collector's action, but it is the cluster connection
|
|
127
|
+
// that is down rather than one read.
|
|
128
|
+
if (errorCodeOf(error) === 'cluster_disconnected') {
|
|
129
|
+
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
130
|
+
}
|
|
131
|
+
// Radar's own identity or collection can't serve the read: neither
|
|
132
|
+
// the cluster being down nor the reader's access.
|
|
133
|
+
if (readActionFor(errorCodeOf(error)) === 'radar_config') {
|
|
134
|
+
return { headline: "Radar can't read this", detail: error.message, icon: ShieldOff }
|
|
135
|
+
}
|
|
124
136
|
// The cluster is healthy and still loading — the retry policies keep
|
|
125
137
|
// these surfaces polling, but a caller without one must not tell the
|
|
126
138
|
// user a connecting cluster is down.
|
|
@@ -36,6 +36,26 @@ describe('MultiSelectPicker', () => {
|
|
|
36
36
|
expect(html).toContain('>Done</button>')
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
it('adds an Only button to each row only when a select-only handler is given', () => {
|
|
40
|
+
expect(render()).not.toContain('>Only</button>')
|
|
41
|
+
|
|
42
|
+
const html = render({ onSelectOnly: () => {} })
|
|
43
|
+
expect(html.match(/>Only<\/button>/g)).toHaveLength(KINDS.length)
|
|
44
|
+
expect(html).toContain('aria-label="Show only Pod"')
|
|
45
|
+
// The name stays inside the checkbox label, so clicking it still toggles.
|
|
46
|
+
expect(html).toMatch(/<label[^>]*><span[^>]*><input type="checkbox"[^>]*\/><span class="truncate">Pod<\/span><\/span><\/label><button/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('hides Only until hover or keyboard focus, and keeps it visible on touch', () => {
|
|
50
|
+
const html = render({ onSelectOnly: () => {} })
|
|
51
|
+
const only = html.match(/<button[^>]*aria-label="Show only Pod"[^>]*>/)?.[0] ?? ''
|
|
52
|
+
expect(only).toContain('opacity-0')
|
|
53
|
+
expect(only).toContain('group-hover/row:opacity-100')
|
|
54
|
+
expect(only).toContain('group-has-[:focus-visible]/row:opacity-100')
|
|
55
|
+
expect(only).toContain('focus-visible:opacity-100')
|
|
56
|
+
expect(only).toContain('any-pointer-coarse:opacity-100')
|
|
57
|
+
})
|
|
58
|
+
|
|
39
59
|
it('shows the filter input above the threshold and hides it below', () => {
|
|
40
60
|
const shown = render()
|
|
41
61
|
expect(shown).toContain('placeholder="Filter kinds"')
|
|
@@ -16,6 +16,12 @@ export interface MultiSelectPickerProps {
|
|
|
16
16
|
onClearAll: () => void
|
|
17
17
|
/** Footer "Done" button. */
|
|
18
18
|
onDone: () => void
|
|
19
|
+
/**
|
|
20
|
+
* When set, each row gets an "Only" button that selects just that item. The
|
|
21
|
+
* name and checkbox still toggle. The host decides whether it also commits
|
|
22
|
+
* and closes.
|
|
23
|
+
*/
|
|
24
|
+
onSelectOnly?: (item: string) => void
|
|
19
25
|
|
|
20
26
|
/** Controlled search term (kept in the host so the body stays SSR-testable). */
|
|
21
27
|
search: string
|
|
@@ -50,6 +56,7 @@ export function MultiSelectPicker({
|
|
|
50
56
|
onSelectionChange,
|
|
51
57
|
onClearAll,
|
|
52
58
|
onDone,
|
|
59
|
+
onSelectOnly,
|
|
53
60
|
search,
|
|
54
61
|
onSearchChange,
|
|
55
62
|
searchPlaceholder,
|
|
@@ -136,8 +143,8 @@ export function MultiSelectPicker({
|
|
|
136
143
|
)}
|
|
137
144
|
|
|
138
145
|
{filteredItems.map(item => (
|
|
139
|
-
<li key={item}>
|
|
140
|
-
<label className="w-
|
|
146
|
+
<li key={item} className="group/row flex items-center hover:bg-theme-hover">
|
|
147
|
+
<label className="flex-1 min-w-0 flex items-center px-3 py-1.5 text-sm text-left text-theme-text-primary cursor-pointer">
|
|
141
148
|
<span className="flex items-center gap-2 min-w-0">
|
|
142
149
|
<input
|
|
143
150
|
type="checkbox"
|
|
@@ -149,6 +156,17 @@ export function MultiSelectPicker({
|
|
|
149
156
|
{renderItemMeta?.(item)}
|
|
150
157
|
</span>
|
|
151
158
|
</label>
|
|
159
|
+
{onSelectOnly && (
|
|
160
|
+
// Touchscreens can coexist with a hover-capable mouse.
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => onSelectOnly(item)}
|
|
164
|
+
aria-label={`Show only ${item}`}
|
|
165
|
+
className="shrink-0 mr-2 px-1.5 py-0.5 rounded border border-theme-border bg-theme-surface text-[11px] text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 focus-visible:opacity-100 any-pointer-coarse:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
166
|
+
>
|
|
167
|
+
Only
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
152
170
|
</li>
|
|
153
171
|
))}
|
|
154
172
|
</ul>
|
|
@@ -18,7 +18,9 @@ interface PageHeaderProps {
|
|
|
18
18
|
|
|
19
19
|
export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
|
|
20
20
|
return (
|
|
21
|
-
|
|
21
|
+
// Phones: actions (freshness, summary tiles) wrap under the title
|
|
22
|
+
// instead of squeezing it to nothing.
|
|
23
|
+
<div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
|
|
22
24
|
{onBack && (
|
|
23
25
|
<button
|
|
24
26
|
onClick={onBack}
|
|
@@ -28,16 +30,16 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
|
|
|
28
30
|
<ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
|
|
29
31
|
</button>
|
|
30
32
|
)}
|
|
31
|
-
<div className="flex-1 min-w-0">
|
|
33
|
+
<div className="flex-1 min-w-0 phone:basis-full">
|
|
32
34
|
<div className="flex items-center gap-2">
|
|
33
35
|
<Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
|
|
34
36
|
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
|
|
35
37
|
</div>
|
|
36
38
|
{description && (
|
|
37
|
-
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
|
|
39
|
+
<p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
|
|
38
40
|
)}
|
|
39
41
|
</div>
|
|
40
|
-
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
|
|
42
|
+
{actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
|
|
41
43
|
</div>
|
|
42
44
|
)
|
|
43
45
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'
|
|
2
2
|
import { DURATION_TOAST_EXIT } from '../../utils/animation'
|
|
3
3
|
import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
@@ -15,15 +15,13 @@ interface Toast {
|
|
|
15
15
|
onDetailClick?: () => void
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type ToastOptions = Omit<Toast, 'id' | 'message' | 'dismissing'> & {
|
|
19
|
+
id?: string
|
|
20
|
+
duration?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
interface ToastContextType {
|
|
19
|
-
showToast: (message: string, options?:
|
|
20
|
-
detail?: string
|
|
21
|
-
command?: string
|
|
22
|
-
type?: Toast['type']
|
|
23
|
-
position?: { x: number; y: number }
|
|
24
|
-
action?: Toast['action']
|
|
25
|
-
onDetailClick?: () => void
|
|
26
|
-
}) => void
|
|
24
|
+
showToast: (message: string, options?: ToastOptions) => void
|
|
27
25
|
showCopied: (command: string, label?: string, event?: React.MouseEvent) => void
|
|
28
26
|
showError: (message: string, detail?: string) => void
|
|
29
27
|
showSuccess: (message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void
|
|
@@ -94,34 +92,42 @@ export function useToast() {
|
|
|
94
92
|
|
|
95
93
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
96
94
|
const [toasts, setToasts] = useState<Toast[]>([])
|
|
95
|
+
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
|
97
96
|
|
|
98
97
|
const animateDismiss = useCallback((id: string) => {
|
|
98
|
+
clearTimeout(timers.current.get(id))
|
|
99
99
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, dismissing: true } : t))
|
|
100
|
-
setTimeout(() => {
|
|
100
|
+
timers.current.set(id, setTimeout(() => {
|
|
101
101
|
setToasts(prev => prev.filter(t => t.id !== id))
|
|
102
|
-
|
|
102
|
+
timers.current.delete(id)
|
|
103
|
+
}, DURATION_TOAST_EXIT))
|
|
103
104
|
}, [])
|
|
104
105
|
|
|
105
|
-
const showToast = useCallback((message: string, options?: {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
type?: Toast['type']
|
|
109
|
-
position?: { x: number; y: number }
|
|
110
|
-
action?: Toast['action']
|
|
111
|
-
onDetailClick?: () => void
|
|
112
|
-
}) => {
|
|
113
|
-
const id = Math.random().toString(36).slice(2)
|
|
114
|
-
const toast: Toast = { id, message, ...options }
|
|
106
|
+
const showToast = useCallback((message: string, options?: ToastOptions) => {
|
|
107
|
+
const { id = Math.random().toString(36).slice(2), duration, ...toastOptions } = options ?? {}
|
|
108
|
+
const toast: Toast = { id, message, ...toastOptions }
|
|
115
109
|
|
|
116
|
-
|
|
110
|
+
clearTimeout(timers.current.get(id))
|
|
111
|
+
timers.current.delete(id)
|
|
112
|
+
setToasts(prev => prev.some(t => t.id === id)
|
|
113
|
+
? prev.map(t => t.id === id ? toast : t)
|
|
114
|
+
: [...prev, toast])
|
|
117
115
|
|
|
118
116
|
// Auto-dismiss: errors stay longer (10s), others 7s
|
|
119
|
-
const dismissTime =
|
|
120
|
-
|
|
121
|
-
animateDismiss(id)
|
|
122
|
-
}
|
|
117
|
+
const dismissTime = duration ?? (toast.type === 'error' ? 10000 : 7000)
|
|
118
|
+
if (dismissTime > 0) {
|
|
119
|
+
timers.current.set(id, setTimeout(() => animateDismiss(id), dismissTime))
|
|
120
|
+
}
|
|
123
121
|
}, [animateDismiss])
|
|
124
122
|
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const activeTimers = timers.current
|
|
125
|
+
return () => {
|
|
126
|
+
activeTimers.forEach(clearTimeout)
|
|
127
|
+
activeTimers.clear()
|
|
128
|
+
}
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
125
131
|
const showCopied = useCallback((command: string, label?: string, event?: React.MouseEvent) => {
|
|
126
132
|
navigator.clipboard.writeText(command)
|
|
127
133
|
|
|
@@ -187,6 +193,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|
|
187
193
|
|
|
188
194
|
return (
|
|
189
195
|
<div
|
|
196
|
+
role="status"
|
|
190
197
|
className={clsx(
|
|
191
198
|
'flex items-start gap-3 p-4 rounded-lg shadow-2xl border backdrop-blur-sm',
|
|
192
199
|
'w-[480px] max-w-[calc(100vw-32px)]',
|