@spinnaker/core 2025.1.4 → 2025.2.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.
@@ -0,0 +1,3 @@
1
+ /// <reference types="react" />
2
+ import type { ILoadBalancerDetailsProps } from './LoadBalancerDetailsWrapper';
3
+ export declare function LoadBalancerDetailsContent({ app, loadBalancer: params, useDetails, Actions, sections, }: ILoadBalancerDetailsProps): JSX.Element;
@@ -0,0 +1,37 @@
1
+ import type { FunctionComponent } from 'react';
2
+ import type { Application } from '../../application';
3
+ import type { ILoadBalancer } from '../../domain';
4
+ import type { ILoadBalancerStateParams } from '../loadBalancer.states';
5
+ import type { IOverridableProps } from '../../overrideRegistry';
6
+ export interface ILoadBalancerDetailsWrapperProps extends IOverridableProps {
7
+ app: Application;
8
+ loadBalancer: ILoadBalancerStateParams;
9
+ }
10
+ export interface UseDetailsResult<T> {
11
+ data: T | undefined;
12
+ loading: boolean;
13
+ error: string | null;
14
+ refetch: () => Promise<void>;
15
+ }
16
+ export interface IUseDetailsHookProps {
17
+ app: Application;
18
+ loadBalancerParams: ILoadBalancerStateParams;
19
+ autoClose: () => void;
20
+ }
21
+ export type UseDetailsHook<T> = (props: IUseDetailsHookProps) => UseDetailsResult<T>;
22
+ export interface ILoadBalancerDetailsSectionProps {
23
+ app: Application;
24
+ loadBalancer: ILoadBalancer;
25
+ }
26
+ export interface ILoadBalancerActionsProps {
27
+ app: Application;
28
+ loadBalancer: ILoadBalancer;
29
+ }
30
+ export interface ILoadBalancerDetailsProps extends ILoadBalancerDetailsWrapperProps {
31
+ useDetails: UseDetailsHook<ILoadBalancer>;
32
+ Actions: FunctionComponent<ILoadBalancerActionsProps>;
33
+ sections: Array<FunctionComponent<ILoadBalancerDetailsSectionProps>>;
34
+ }
35
+ declare function LoadBalancerDetailsWrapper({ app, loadBalancer }: ILoadBalancerDetailsWrapperProps): JSX.Element;
36
+ export declare const LoadBalancerDetails: typeof LoadBalancerDetailsWrapper;
37
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from './LoadBalancerDetails';
2
+ export * from './LoadBalancerDetailsWrapper';
@@ -6,3 +6,4 @@ export * from './LoadBalancerClusterContainer';
6
6
  export * from './LoadBalancerInstances';
7
7
  export * from './LoadBalancerServerGroup';
8
8
  export * from './LoadBalancersTagWrapper';
9
+ export * from './details';
@@ -1 +1,8 @@
1
+ export interface ILoadBalancerStateParams {
2
+ name: string;
3
+ accountId: string;
4
+ region: string;
5
+ vpcId: string;
6
+ provider: string;
7
+ }
1
8
  export declare const LOAD_BALANCER_STATES = "spinnaker.core.loadBalancer.states";
@@ -3,3 +3,4 @@ export * from './ModalHeader';
3
3
  export * from './ModalBody';
4
4
  export * from './ModalFooter';
5
5
  export * from './showModal';
6
+ export * from './useModal';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Custom hook to manage modal visibility.
3
+ * Provides methods to open and close a modal.
4
+ *
5
+ * @param {UseModalProps} [props] - Configuration options for initial modal state.
6
+ * @param {boolean} [props.defaultOpen=false] - Initial visibility state of the modal.
7
+ * @returns {UseModalReturn} - The current state and control methods for the modal.
8
+ */
9
+ export type UseModalReturn = {
10
+ open: boolean;
11
+ show: () => void;
12
+ close: () => void;
13
+ };
14
+ /**
15
+ * Configuration options for the useModal hook.
16
+ *
17
+ * @typedef {Object} UseModalProps
18
+ * @property {boolean} [defaultOpen=false] - Initial state of the modal.
19
+ */
20
+ export type UseModalProps = {
21
+ defaultOpen?: boolean;
22
+ };
23
+ /**
24
+ * Hook to control modal visibility.
25
+ * Returns methods to open and close a modal, and the modal's current state.
26
+ *
27
+ * @param {UseModalProps} [props] - The configuration for the modal.
28
+ * @returns {UseModalReturn} - Modal visibility state and control methods.
29
+ */
30
+ export declare const useModal: ({ defaultOpen }?: UseModalProps) => UseModalReturn;
@@ -1,6 +1,7 @@
1
1
  export * from './PlatformHealthOverrideMessage';
2
2
  export * from './monitor/TaskMonitor';
3
3
  export * from './monitor/TaskMonitorWrapper';
4
+ export * from './monitor/useTaskMonitor';
4
5
  export * from './modal/TaskReason';
5
6
  export * from './modal/TaskMonitorModal';
6
7
  export * from './task.read.service';
@@ -0,0 +1,25 @@
1
+ import type { ITaskMonitorConfig } from './TaskMonitor';
2
+ import { TaskMonitor } from './TaskMonitor';
3
+ /**
4
+ * React hook that returns a TaskMonitor
5
+ *
6
+ * @param config a ITaskMonitorConfig
7
+ * @param dismissModal a function that closes the modal enclosing the task monitor
8
+ *
9
+ * Example:
10
+ *
11
+ * function MyComponent(props) {
12
+ * const { application, serverGroup } = props;
13
+ * const title = `Resize ${serverGroup.name}`;
14
+ * const taskMonitor = useTaskMonitor({ application, title });
15
+ *
16
+ * return (
17
+ * <>
18
+ * <TaskMonitorWrapper taskMonitor={taskMonitor}>
19
+ * <form onSubmit={() => taskMonitor.submit(() => API.runSomeTask())}>
20
+ * </>
21
+ * )
22
+ * }
23
+ *
24
+ */
25
+ export declare const useTaskMonitor: (config: ITaskMonitorConfig, dismissModal: () => void) => TaskMonitor;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spinnaker/core",
3
3
  "license": "Apache-2.0",
4
- "version": "2025.1.4",
4
+ "version": "2025.2.1",
5
5
  "module": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "publishConfig": {
@@ -124,5 +124,5 @@
124
124
  "shx": "0.3.3",
125
125
  "typescript": "5.0.4"
126
126
  },
127
- "gitHead": "9d7aa2b1efc3f4fcb545a8578ee72e41f4cf88cd"
127
+ "gitHead": "33bad18275d63c4000a8e757cc0b4816e2e7eed5"
128
128
  }
@@ -13,6 +13,7 @@ export interface ILoadBalancerSourceData {
13
13
  }
14
14
 
15
15
  export interface ILoadBalancer extends ITaggedEntity, IManagedResource {
16
+ [key: string]: any;
16
17
  account?: string;
17
18
  cloudProvider?: string;
18
19
  detail?: string;
@@ -0,0 +1,64 @@
1
+ import { useRouter } from '@uirouter/react';
2
+ import React, { useEffect, useState } from 'react';
3
+
4
+ import type { ILoadBalancerDetailsProps } from './LoadBalancerDetailsWrapper';
5
+ import { CloudProviderLogo } from '../../cloudProvider';
6
+ import { EntityNotifications } from '../../entityTag/notifications/EntityNotifications';
7
+ import { Details } from '../../presentation';
8
+ import { IfFeatureEnabled } from '../../utils';
9
+
10
+ export function LoadBalancerDetailsContent({
11
+ app,
12
+ loadBalancer: params,
13
+ useDetails,
14
+ Actions,
15
+ sections,
16
+ }: ILoadBalancerDetailsProps) {
17
+ const [initialized, setInitialized] = useState(false);
18
+ const {
19
+ stateService: { go },
20
+ } = useRouter();
21
+ const autoClose = () => {
22
+ go('^', { allowModalToStayOpen: true }, { location: 'replace' });
23
+ };
24
+ const { data: loadBalancer, loading } = useDetails({ app, loadBalancerParams: params, autoClose });
25
+
26
+ useEffect(() => {
27
+ if (loadBalancer) {
28
+ setInitialized(true);
29
+ }
30
+ }, [loading]);
31
+
32
+ if (!initialized) return <Details loading={loading} />;
33
+
34
+ return (
35
+ <Details loading={loading}>
36
+ <Details.Header
37
+ icon={<CloudProviderLogo provider={params.provider} height="36px" width="36px" />}
38
+ name={loadBalancer.displayName ? loadBalancer.displayName : loadBalancer.name}
39
+ notifications={
40
+ <IfFeatureEnabled
41
+ feature="entityTags"
42
+ render={
43
+ <EntityNotifications
44
+ entity={loadBalancer}
45
+ application={app}
46
+ placement="bottom"
47
+ hOffsetPercent="90%"
48
+ entityType="loadBalancer"
49
+ pageLocation="details"
50
+ onUpdate={() => app.loadBalancers.refresh()}
51
+ />
52
+ }
53
+ />
54
+ }
55
+ actions={<Actions key="actions" app={app} loadBalancer={loadBalancer} />}
56
+ />
57
+ <Details.Content loading={loading}>
58
+ {sections.map((Section, index) => (
59
+ <Section key={index} app={app} loadBalancer={loadBalancer} />
60
+ ))}
61
+ </Details.Content>
62
+ </Details>
63
+ );
64
+ }
@@ -0,0 +1,120 @@
1
+ import { $templateCache } from 'ngimport';
2
+ import type { FunctionComponent } from 'react';
3
+ import React from 'react';
4
+
5
+ import { LoadBalancerDetailsContent } from './LoadBalancerDetails';
6
+ import type { Application } from '../../application';
7
+ import { CloudProviderRegistry } from '../../cloudProvider';
8
+ import type { ILoadBalancer } from '../../domain';
9
+ import type { ILoadBalancerStateParams } from '../loadBalancer.states';
10
+ import type { IOverridableProps } from '../../overrideRegistry';
11
+ import { overridableComponent } from '../../overrideRegistry';
12
+ import { useData } from '../../presentation';
13
+ import { AngularJSAdapter } from '../../reactShims';
14
+
15
+ export interface ILoadBalancerDetailsWrapperProps extends IOverridableProps {
16
+ app: Application;
17
+ loadBalancer: ILoadBalancerStateParams;
18
+ }
19
+
20
+ export interface UseDetailsResult<T> {
21
+ data: T | undefined;
22
+ loading: boolean;
23
+ error: string | null;
24
+ refetch: () => Promise<void>;
25
+ }
26
+
27
+ export interface IUseDetailsHookProps {
28
+ app: Application;
29
+ loadBalancerParams: ILoadBalancerStateParams;
30
+ autoClose: () => void;
31
+ }
32
+
33
+ export type UseDetailsHook<T> = (props: IUseDetailsHookProps) => UseDetailsResult<T>;
34
+
35
+ export interface ILoadBalancerDetailsSectionProps {
36
+ app: Application;
37
+ loadBalancer: ILoadBalancer;
38
+ }
39
+
40
+ export interface ILoadBalancerActionsProps {
41
+ app: Application;
42
+ loadBalancer: ILoadBalancer;
43
+ }
44
+
45
+ export interface ILoadBalancerDetailsProps extends ILoadBalancerDetailsWrapperProps {
46
+ useDetails: UseDetailsHook<ILoadBalancer>;
47
+ Actions: FunctionComponent<ILoadBalancerActionsProps>;
48
+ sections: Array<FunctionComponent<ILoadBalancerDetailsSectionProps>>;
49
+ }
50
+
51
+ interface IDetailsTemplateState {
52
+ detailsTemplateUrl?: string;
53
+ detailsController?: string;
54
+ useDetailsHook?: UseDetailsHook<ILoadBalancer>;
55
+ detailsActions?: FunctionComponent<ILoadBalancerActionsProps>;
56
+ detailsSections?: Array<FunctionComponent<ILoadBalancerDetailsSectionProps>>;
57
+ }
58
+
59
+ const getDetailsTemplate = (provider: string): Promise<IDetailsTemplateState> =>
60
+ Promise.all([
61
+ CloudProviderRegistry.getValue(provider, 'loadBalancer.useDetailsHook'),
62
+ CloudProviderRegistry.getValue(provider, 'loadBalancer.detailsActions'),
63
+ CloudProviderRegistry.getValue(provider, 'loadBalancer.detailsSections'),
64
+ CloudProviderRegistry.getValue(provider, 'loadBalancer.detailsTemplateUrl'),
65
+ CloudProviderRegistry.getValue(provider, 'loadBalancer.detailsController'),
66
+ ]).then(([useDetailsHook, detailsActions, detailsSections, templateUrl, detailsController]) => {
67
+ const detailsTemplateUrl = templateUrl ? $templateCache.get<string>(templateUrl) : undefined;
68
+ return {
69
+ useDetailsHook,
70
+ detailsActions,
71
+ detailsSections,
72
+ detailsTemplateUrl,
73
+ detailsController,
74
+ };
75
+ });
76
+
77
+ function LoadBalancerDetailsWrapper({ app, loadBalancer }: ILoadBalancerDetailsWrapperProps) {
78
+ const { provider } = loadBalancer;
79
+
80
+ const { result: detailsTemplate } = useData<IDetailsTemplateState>(() => getDetailsTemplate(provider), {}, [
81
+ provider,
82
+ ]);
83
+
84
+ const {
85
+ useDetailsHook,
86
+ detailsActions: DetailsActions,
87
+ detailsSections,
88
+ detailsTemplateUrl,
89
+ detailsController,
90
+ } = detailsTemplate;
91
+
92
+ if (useDetailsHook && DetailsActions && detailsSections) {
93
+ // React rendering
94
+ return (
95
+ <LoadBalancerDetailsContent
96
+ app={app}
97
+ loadBalancer={loadBalancer}
98
+ useDetails={useDetailsHook}
99
+ Actions={DetailsActions}
100
+ sections={detailsSections}
101
+ />
102
+ );
103
+ }
104
+
105
+ if (detailsTemplateUrl && detailsController) {
106
+ // Angular rendering
107
+ return (
108
+ <AngularJSAdapter
109
+ className="detail-content flex-container-h"
110
+ template={detailsTemplateUrl}
111
+ controller={`${detailsController} as ctrl`}
112
+ locals={{ app, loadBalancer }}
113
+ />
114
+ );
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ export const LoadBalancerDetails = overridableComponent(LoadBalancerDetailsWrapper, 'loadBalancer.details');
@@ -0,0 +1,2 @@
1
+ export * from './LoadBalancerDetails';
2
+ export * from './LoadBalancerDetailsWrapper';
@@ -7,3 +7,4 @@ export * from './LoadBalancerClusterContainer';
7
7
  export * from './LoadBalancerInstances';
8
8
  export * from './LoadBalancerServerGroup';
9
9
  export * from './LoadBalancersTagWrapper';
10
+ export * from './details';
@@ -1,14 +1,22 @@
1
1
  import type { StateParams } from '@uirouter/angularjs';
2
2
  import { module } from 'angular';
3
3
 
4
- import { LoadBalancerDetails } from './LoadBalancerDetails';
5
4
  import { LoadBalancers } from './LoadBalancers';
6
5
  import type { ApplicationStateProvider } from '../application';
7
6
  import { APPLICATION_STATE_PROVIDER } from '../application';
7
+ import { LoadBalancerDetails } from './details';
8
8
  import { filterModelConfig } from './filter/LoadBalancerFilterModel';
9
9
  import { LoadBalancerFilters } from './filter/LoadBalancerFilters';
10
10
  import type { INestedState, StateConfigProvider } from '../navigation';
11
11
 
12
+ export interface ILoadBalancerStateParams {
13
+ name: string;
14
+ accountId: string;
15
+ region: string;
16
+ vpcId: string;
17
+ provider: string;
18
+ }
19
+
12
20
  export const LOAD_BALANCER_STATES = 'spinnaker.core.loadBalancer.states';
13
21
  module(LOAD_BALANCER_STATES, [APPLICATION_STATE_PROVIDER]).config([
14
22
  'applicationStateProvider',
@@ -33,12 +41,13 @@ module(LOAD_BALANCER_STATES, [APPLICATION_STATE_PROVIDER]).config([
33
41
  accountId: ['$stateParams', ($stateParams: StateParams) => $stateParams.accountId],
34
42
  loadBalancer: [
35
43
  '$stateParams',
36
- ($stateParams: StateParams) => {
44
+ ($stateParams: StateParams): ILoadBalancerStateParams => {
37
45
  return {
38
46
  name: $stateParams.name,
39
47
  accountId: $stateParams.accountId,
40
48
  region: $stateParams.region,
41
49
  vpcId: $stateParams.vpcId,
50
+ provider: $stateParams.provider,
42
51
  };
43
52
  },
44
53
  ],
@@ -0,0 +1,208 @@
1
+ import { shallow } from 'enzyme';
2
+ import React from 'react';
3
+ import { Subject } from 'rxjs';
4
+
5
+ import { JobManifestPodLogs } from './JobManifestPodLogs';
6
+ import { JobStageExecutionLogs } from './JobStageExecutionLogs';
7
+ import { ManifestReader } from '../ManifestReader';
8
+ import type { IPodNameProvider } from '../PodNameProvider';
9
+ import type { Application } from '../../application';
10
+
11
+ describe('JobStageExecutionLogs', () => {
12
+ const mockApplication = {} as Application;
13
+ const mockManifest: {
14
+ manifest: { metadata: { name: string; namespace: string }; spec: {}; status: {} };
15
+ name: string;
16
+ moniker: { app: string; cluster: string };
17
+ account: string;
18
+ } = {
19
+ account: 'test-account',
20
+ name: 'test-manifest',
21
+ moniker: {
22
+ app: 'testapp',
23
+ cluster: 'testcluster',
24
+ },
25
+ manifest: {
26
+ metadata: {
27
+ name: 'test-job',
28
+ namespace: 'test-namespace',
29
+ },
30
+ spec: {},
31
+ status: {},
32
+ },
33
+ };
34
+ const mockPodNamesProviders: IPodNameProvider[] = [
35
+ {
36
+ getPodName: () => 'test-pod',
37
+ },
38
+ ];
39
+
40
+ let getManifestSpy: jasmine.Spy;
41
+ const subject = new Subject();
42
+
43
+ beforeEach(() => {
44
+ getManifestSpy = spyOn(ManifestReader, 'getManifest').and.returnValue(subject);
45
+ });
46
+
47
+ afterEach(() => {
48
+ getManifestSpy.calls.reset();
49
+ });
50
+
51
+ it('should fetch manifest on mount', () => {
52
+ shallow(
53
+ <JobStageExecutionLogs
54
+ deployedName="test-job"
55
+ account="test-account"
56
+ application={mockApplication}
57
+ externalLink=""
58
+ podNamesProviders={mockPodNamesProviders}
59
+ location="test-namespace"
60
+ />,
61
+ );
62
+
63
+ expect(getManifestSpy).toHaveBeenCalledWith('test-account', 'test-namespace', 'test-job');
64
+ });
65
+
66
+ it('should render JobManifestPodLogs when location is provided and no externalLink', () => {
67
+ const wrapper = shallow(
68
+ <JobStageExecutionLogs
69
+ deployedName="test-job"
70
+ account="test-account"
71
+ application={mockApplication}
72
+ externalLink=""
73
+ podNamesProviders={mockPodNamesProviders}
74
+ location="test-namespace"
75
+ />,
76
+ );
77
+
78
+ subject.next(mockManifest);
79
+ wrapper.update();
80
+
81
+ const podLogs = wrapper.find(JobManifestPodLogs);
82
+ expect(podLogs.exists()).toBeTruthy();
83
+ expect(podLogs.props()).toEqual({
84
+ account: 'test-account',
85
+ location: 'test-namespace',
86
+ podNamesProviders: mockPodNamesProviders,
87
+ linkName: 'Console Output',
88
+ });
89
+ });
90
+
91
+ it('should not render JobManifestPodLogs when location is not provided', () => {
92
+ const wrapper = shallow(
93
+ <JobStageExecutionLogs
94
+ deployedName="test-job"
95
+ account="test-account"
96
+ application={mockApplication}
97
+ externalLink=""
98
+ podNamesProviders={mockPodNamesProviders}
99
+ location=""
100
+ />,
101
+ );
102
+
103
+ subject.next(mockManifest);
104
+ wrapper.update();
105
+
106
+ expect(wrapper.find(JobManifestPodLogs).exists()).toBeFalsy();
107
+ });
108
+
109
+ it('should render external link when provided and manifest is not empty', () => {
110
+ const wrapper = shallow(
111
+ <JobStageExecutionLogs
112
+ deployedName="test-job"
113
+ account="test-account"
114
+ application={mockApplication}
115
+ externalLink="https://example.com/logs"
116
+ podNamesProviders={mockPodNamesProviders}
117
+ location="test-namespace"
118
+ />,
119
+ );
120
+
121
+ subject.next(mockManifest);
122
+ wrapper.update();
123
+
124
+ const link = wrapper.find('a');
125
+ expect(link.exists()).toBeTruthy();
126
+ expect(link.prop('href')).toBe('https://example.com/logs');
127
+ expect(link.text()).toBe('Console Output (External)');
128
+ });
129
+
130
+ it('should render external link with template variables', () => {
131
+ const wrapper = shallow(
132
+ <JobStageExecutionLogs
133
+ deployedName="test-job"
134
+ account="test-account"
135
+ application={mockApplication}
136
+ externalLink="https://example.com/logs/{{manifest.metadata.namespace}}/{{manifest.metadata.name}}"
137
+ podNamesProviders={mockPodNamesProviders}
138
+ location="test-namespace"
139
+ />,
140
+ );
141
+
142
+ subject.next(mockManifest);
143
+ wrapper.update();
144
+
145
+ const link = wrapper.find('a');
146
+ expect(link.exists()).toBeTruthy();
147
+ expect(link.prop('href')).toBe('https://example.com/logs/test-namespace/test-job');
148
+ });
149
+
150
+ it('should not render external link with templates when manifest is empty', () => {
151
+ const wrapper = shallow(
152
+ <JobStageExecutionLogs
153
+ deployedName="test-job"
154
+ account="test-account"
155
+ application={mockApplication}
156
+ externalLink="https://example.com/logs/{{manifest.metadata.namespace}}/{{manifest.metadata.name}}"
157
+ podNamesProviders={mockPodNamesProviders}
158
+ location="test-namespace"
159
+ />,
160
+ );
161
+
162
+ // Don't call subject.next() to simulate empty manifest state
163
+
164
+ const podLogs = wrapper.find(JobManifestPodLogs);
165
+ expect(podLogs.exists()).toBeTruthy();
166
+ expect(wrapper.find('a').exists()).toBeFalsy();
167
+ });
168
+
169
+ it('should not template link if it does not include template syntax', () => {
170
+ const externalLink = 'https://example.com/logs';
171
+ const wrapper = shallow(
172
+ <JobStageExecutionLogs
173
+ deployedName="test-job"
174
+ account="test-account"
175
+ application={mockApplication}
176
+ externalLink={externalLink}
177
+ podNamesProviders={mockPodNamesProviders}
178
+ location="test-namespace"
179
+ />,
180
+ );
181
+
182
+ subject.next(mockManifest);
183
+ wrapper.update();
184
+
185
+ const link = wrapper.find('a');
186
+ expect(link.prop('href')).toBe(externalLink);
187
+ });
188
+
189
+ it('should handle errors in manifest fetching gracefully', () => {
190
+ const wrapper = shallow(
191
+ <JobStageExecutionLogs
192
+ deployedName="test-job"
193
+ account="test-account"
194
+ application={mockApplication}
195
+ externalLink=""
196
+ podNamesProviders={mockPodNamesProviders}
197
+ location="test-namespace"
198
+ />,
199
+ );
200
+
201
+ // Simulate an error
202
+ subject.error(new Error('Failed to fetch manifest'));
203
+ wrapper.update();
204
+
205
+ // Component shouldn't crash and should render the JobManifestPodLogs as fallback
206
+ expect(wrapper.find(JobManifestPodLogs).exists()).toBeTruthy();
207
+ });
208
+ });
@@ -1,4 +1,4 @@
1
- import { template } from 'lodash';
1
+ import { isEmpty, template } from 'lodash';
2
2
  import React from 'react';
3
3
  import { from as observableFrom, Subject } from 'rxjs';
4
4
  import { takeUntil } from 'rxjs/operators';
@@ -52,7 +52,7 @@ export class JobStageExecutionLogs extends React.Component<IJobStageExecutionLog
52
52
  const { manifest } = this.state;
53
53
  const { externalLink, podNamesProviders, location, account } = this.props;
54
54
  // prefer links to external logging platforms
55
- if (externalLink) {
55
+ if (externalLink && (!externalLink.includes('{{') || !isEmpty(manifest))) {
56
56
  return (
57
57
  <a target="_blank" href={this.renderExternalLink(externalLink, manifest)}>
58
58
  Console Output (External)
@@ -3,3 +3,4 @@ export * from './ModalHeader';
3
3
  export * from './ModalBody';
4
4
  export * from './ModalFooter';
5
5
  export * from './showModal';
6
+ export * from './useModal';
@@ -0,0 +1,45 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ /**
4
+ * Custom hook to manage modal visibility.
5
+ * Provides methods to open and close a modal.
6
+ *
7
+ * @param {UseModalProps} [props] - Configuration options for initial modal state.
8
+ * @param {boolean} [props.defaultOpen=false] - Initial visibility state of the modal.
9
+ * @returns {UseModalReturn} - The current state and control methods for the modal.
10
+ */
11
+ export type UseModalReturn = {
12
+ open: boolean;
13
+ show: () => void;
14
+ close: () => void;
15
+ };
16
+
17
+ /**
18
+ * Configuration options for the useModal hook.
19
+ *
20
+ * @typedef {Object} UseModalProps
21
+ * @property {boolean} [defaultOpen=false] - Initial state of the modal.
22
+ */
23
+ export type UseModalProps = {
24
+ defaultOpen?: boolean;
25
+ };
26
+
27
+ /**
28
+ * Hook to control modal visibility.
29
+ * Returns methods to open and close a modal, and the modal's current state.
30
+ *
31
+ * @param {UseModalProps} [props] - The configuration for the modal.
32
+ * @returns {UseModalReturn} - Modal visibility state and control methods.
33
+ */
34
+ export const useModal = ({ defaultOpen = false }: UseModalProps = {}): UseModalReturn => {
35
+ const [open, setOpen] = useState(defaultOpen);
36
+
37
+ const show = useCallback(() => setOpen(true), [open]);
38
+ const close = useCallback(() => setOpen(false), [open]);
39
+
40
+ return {
41
+ open,
42
+ show,
43
+ close,
44
+ };
45
+ };
package/src/task/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './PlatformHealthOverrideMessage';
2
2
  export * from './monitor/TaskMonitor';
3
3
  export * from './monitor/TaskMonitorWrapper';
4
+ export * from './monitor/useTaskMonitor';
4
5
  export * from './modal/TaskReason';
5
6
  export * from './modal/TaskMonitorModal';
6
7
  export * from './task.read.service';