Skip to content

Commit e7c2eb9

Browse files
authored
Merge pull request #2277 from HubSpot/costs_plugin
Add optional costs plugin and fix overwhelming err message size
2 parents 3aaae0d + bf44a38 commit e7c2eb9

16 files changed

Lines changed: 346 additions & 86 deletions

File tree

SingularityService/src/main/java/com/hubspot/singularity/config/UIConfiguration.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ public static RootUrlMode parse(String value) {
4343
@JsonProperty
4444
private Optional<String> navColor = Optional.empty();
4545

46+
@JsonProperty
47+
private List<UINavLinkConfiguration> formattedNavLinks = Collections.emptyList();
48+
4649
@JsonProperty
4750
private String baseUrl;
4851

@@ -109,6 +112,7 @@ public static RootUrlMode parse(String value) {
109112

110113
// e.g. {"QA": "https://singularity-qa.my-paas.net", "Production": "https://singularity-prod.my-paas.net"}
111114
@JsonProperty
115+
@Deprecated
112116
private Map<String, String> navTitleLinks = Collections.emptyMap();
113117

114118
@JsonProperty
@@ -117,6 +121,9 @@ public static RootUrlMode parse(String value) {
117121
@JsonProperty
118122
private Optional<String> showRequestButtonsForGroup = Optional.empty();
119123

124+
@JsonProperty
125+
private Optional<String> costsApiUrlFormat = Optional.empty();
126+
120127
public boolean isHideNewDeployButton() {
121128
return hideNewDeployButton;
122129
}
@@ -340,4 +347,20 @@ public Optional<String> getShowRequestButtonsForGroup() {
340347
public void setShowRequestButtonsForGroup(Optional<String> showRequestButtonsForGroup) {
341348
this.showRequestButtonsForGroup = showRequestButtonsForGroup;
342349
}
350+
351+
public Optional<String> getCostsApiUrlFormat() {
352+
return costsApiUrlFormat;
353+
}
354+
355+
public void setCostsApiUrlFormat(Optional<String> costsApiUrlFormat) {
356+
this.costsApiUrlFormat = costsApiUrlFormat;
357+
}
358+
359+
public List<UINavLinkConfiguration> getFormattedNavLinks() {
360+
return formattedNavLinks;
361+
}
362+
363+
public void setFormattedNavLinks(List<UINavLinkConfiguration> formattedNavLinks) {
364+
this.formattedNavLinks = formattedNavLinks;
365+
}
343366
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.hubspot.singularity.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import javax.annotation.Nullable;
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
public class UINavLinkConfiguration {
8+
private String title;
9+
private String linkFormat;
10+
private Boolean divider = false;
11+
private String tooltip;
12+
13+
public String getTitle() {
14+
return title;
15+
}
16+
17+
public void setTitle(String title) {
18+
this.title = title;
19+
}
20+
21+
public String getLinkFormat() {
22+
return linkFormat;
23+
}
24+
25+
public void setLinkFormat(String linkFormat) {
26+
this.linkFormat = linkFormat;
27+
}
28+
29+
public Boolean getDivider() {
30+
return divider;
31+
}
32+
33+
public void setDivider(Boolean divider) {
34+
this.divider = divider;
35+
}
36+
37+
@Nullable
38+
public String getTooltip() {
39+
return tooltip;
40+
}
41+
42+
public void setTooltip(@Nullable String tooltip) {
43+
this.tooltip = tooltip;
44+
}
45+
}

SingularityService/src/main/java/com/hubspot/singularity/views/IndexView.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public class IndexView extends View {
7373
private final String appJsPath;
7474
private final String appCssPath;
7575
private final String vendorJsPath;
76+
private final String costsApiUrlFormat;
7677

7778
public IndexView(
7879
String singularityUriBase,
@@ -170,7 +171,7 @@ public IndexView(
170171
}
171172

172173
try {
173-
this.navTitleLinks = ow.writeValueAsString(uiConfiguration.getNavTitleLinks());
174+
this.navTitleLinks = ow.writeValueAsString(uiConfiguration.getFormattedNavLinks());
174175
} catch (JsonProcessingException e) {
175176
throw new RuntimeException(e);
176177
}
@@ -190,6 +191,7 @@ public IndexView(
190191
} catch (IOException ioe) {
191192
throw new RuntimeException(ioe);
192193
}
194+
this.costsApiUrlFormat = uiConfiguration.getCostsApiUrlFormat().orElse("");
193195
}
194196

195197
public String getAppRoot() {
@@ -356,6 +358,10 @@ public String getShowRequestButtonsForGroup() {
356358
return showRequestButtonsForGroup;
357359
}
358360

361+
public String getCostsApiUrlFormat() {
362+
return costsApiUrlFormat;
363+
}
364+
359365
@Override
360366
public String toString() {
361367
return (
@@ -463,6 +469,9 @@ public String toString() {
463469
", vendorJsPath='" +
464470
vendorJsPath +
465471
'\'' +
472+
", costsApiUrlFormat='" +
473+
costsApiUrlFormat +
474+
'\'' +
466475
"} " +
467476
super.toString()
468477
);

SingularityUI/app/actions/api/base.es6

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function buildApiAction(actionName, opts = {}, keyFunc = undefined) {
4040
window.location.href = config.redirectOnUnauthorizedUrl.replace('{URL}', encodeURIComponent(window.location.href));
4141
} else { // Something else happened, display the error
4242
Messenger().post({
43-
message: `<p>An error occurred while accessing <code>${options.url}</code></p><pre>${err}</pre>`,
43+
message: `<p>An error occurred while accessing <code>${options.url}</code></p><div class='err-message-content'><pre>${err}</pre></div>`,
4444
type: 'error'
4545
});
4646
}
@@ -82,13 +82,16 @@ export function buildApiAction(actionName, opts = {}, keyFunc = undefined) {
8282
options.headers.Authorization = Utils.getAuthTokenHeader();
8383
}
8484

85-
return fetch(config.apiRoot + options.url + userParam, _.extend({credentials: 'include'}, _.omit(options, 'url')))
85+
const baseUrl = options.url.startsWith('https://') ? options.url : config.apiRoot + options.url;
86+
87+
return fetch(baseUrl + userParam, _.extend({credentials: 'include'}, _.omit(options, 'url')))
8688
.then(response => {
8789
apiResponse = response;
8890
if (response.status === 204) {
8991
return Promise.resolve();
9092
}
91-
if (response.headers.get('Content-Type') === 'application/json') {
93+
const contentType = response.headers.get('Content-Type');
94+
if (contentType === 'application/json' || contentType === 'application/json;charset=utf-8') {
9295
// void response cannot be parsed as JSON
9396
if (response.headers.get('Content-Length') === '0') {
9497
return Promise.resolve();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { buildApiAction } from './base';
2+
3+
export const FetchCostData = buildApiAction(
4+
'FETCH_COSTS',
5+
(requestId, costsUrlFormat) => {
6+
const url = costsUrlFormat.replace('{REQUEST_ID}', requestId);
7+
return ({
8+
url: url,
9+
catchStatusCodes: [404]
10+
})
11+
},
12+
(requestId) => requestId
13+
);

SingularityUI/app/assets/index.mustache

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
quickLinks: {{{quickLinks}}},
5656
navTitleLinks: {{{navTitleLinks}}},
5757
lessTerminalPath: "{{{lessTerminalPath}}}",
58-
showRequestButtonsForGroup: "{{{showRequestButtonsForGroup}}}"
58+
showRequestButtonsForGroup: "{{{showRequestButtonsForGroup}}}",
59+
costsApiUrlFormat: "{{{costsApiUrlFormat}}}"
60+
5961
};
6062
</script>
6163
<script src="{{{staticRoot}}}/{{{vendorJsPath}}}"></script>

SingularityUI/app/components/common/Navigation.jsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import classnames from 'classnames';
66
import Utils from '../../utils';
77

88
import { Glyphicon } from 'react-bootstrap';
9+
import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger';
10+
import ToolTip from 'react-bootstrap/lib/Tooltip';
911

1012
function handleSearchClick(event, toggleGlobalSearch) {
1113
event.preventDefault();
@@ -58,11 +60,29 @@ const Navigation = (props) => {
5860
{config.title} <span className="caret" />
5961
</a>
6062
<ul className="dropdown-menu">
61-
{Object.keys(config.navTitleLinks).map((linkTitle, index) =>
62-
<li key={index}>
63-
<a href={config.navTitleLinks[linkTitle].replace('{CURRENT_PATH}', currentPathForLink(props.location.pathname))}>{linkTitle}</a>
64-
</li>
65-
)}
63+
{config.navTitleLinks.map((linkConfig, index) => {
64+
if (linkConfig['divider']) {
65+
return (<li key={index} role="separator" className="divider"></li>);
66+
}
67+
let link = (<a href={linkConfig['linkFormat'].replace('{CURRENT_PATH}', currentPathForLink(props.location.pathname))}>{linkConfig['title']}</a>);
68+
if ('tooltip' in linkConfig) {
69+
const tooltip = (
70+
<ToolTip id="view-nav-tip">
71+
{linkConfig['tooltip']}
72+
</ToolTip>
73+
);
74+
link = (
75+
<OverlayTrigger placement="right" id="view-nav-tip-overlay" overlay={tooltip}>
76+
{link}
77+
</OverlayTrigger>
78+
);
79+
}
80+
return (
81+
<li key={index}>
82+
{link}
83+
</li>
84+
);
85+
})}
6686
</ul>
6787
</li>
6888
</ul>

SingularityUI/app/components/common/table/UITable.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ class UITable extends Component {
187187
);
188188
});
189189

190-
if (sortDirection === UITable.SortDirection.ASC) {
190+
if (sortDirection === UITable.SortDirection.DESC) {
191191
sorted.reverse();
192192
}
193193

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React, { PropTypes } from 'react';
2+
import { connect } from 'react-redux';
3+
4+
import { Col } from 'react-bootstrap';
5+
6+
import CollapsableSection from '../common/CollapsableSection';
7+
import UITable from '../common/table/UITable';
8+
import Column from '../common/table/Column';
9+
import Utils from '../../utils';
10+
11+
const CostsView = ({requestId, costsAPI}) => {
12+
const costs = costsAPI ? costsAPI.data : [];
13+
const title = costs.length ? 'Average Daily Costs ($' + costs.map((c) => c.cost).reduce((p, c) => p + c).toFixed(4) + ')' : 'Average Daily Costs';
14+
return (
15+
<CollapsableSection id="costs" title={title} defaultExpanded={true}>
16+
<UITable
17+
data={costs}
18+
keyGetter={(c) => c.activityType + c.cost + c.costKey+ c.costType}
19+
defaultSortBy={'cost'}
20+
defaultSortDirection={'DESC'}
21+
showPageLoaderWhenFetching={true}
22+
isFetching={!costs.length}
23+
>
24+
<Column
25+
label="Activity Type"
26+
id="activityType"
27+
key="activityType"
28+
cellData={(c) => Utils.humanizeText(c.activityType)}
29+
/>
30+
<Column
31+
label="Cost Primary Key"
32+
id="costKey"
33+
key="costKey"
34+
cellData={(c) => c.primaryKey}
35+
/>
36+
<Column
37+
label="Cost Type"
38+
id="costType"
39+
key="costType"
40+
cellData={(c) => Utils.humanizeText(c.costType)}
41+
/>
42+
<Column
43+
label="Cost"
44+
id="cost"
45+
key="cost"
46+
forceSortHeader={true}
47+
cellData={(c) => c.cost}
48+
cellRender={(c) => '$' + c}
49+
/>
50+
</UITable>
51+
</CollapsableSection>
52+
);
53+
}
54+
55+
CostsView.propTypes = {
56+
requestId: PropTypes.string.isRequired,
57+
costsAPI: PropTypes.object
58+
};
59+
60+
const mapStateToProps = (state, ownProps) => ({
61+
costsAPI: Utils.maybe(state.api.costs, [ownProps.requestId])
62+
});
63+
64+
export default connect(
65+
mapStateToProps
66+
)(CostsView);

SingularityUI/app/components/requestDetail/RequestDetailPage.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { withRouter } from 'react-router';
44

55
import rootComponent from '../../rootComponent';
66
import * as RefreshActions from '../../actions/ui/refresh';
7+
import { FetchCostData } from '../../actions/api/costs';
78
import { FetchRequest } from '../../actions/api/requests';
89
import {
910
FetchActiveTasksForRequest,
@@ -16,6 +17,7 @@ import {
1617
FetchTaskCleanups
1718
} from '../../actions/api/tasks';
1819

20+
import CostsView from './CostsView';
1921
import RequestHeader from './RequestHeader';
2022
import RequestExpiringActions from './RequestExpiringActions';
2123
import ActiveTasksTable from './ActiveTasksTable';
@@ -32,6 +34,10 @@ import { refresh, initialize } from '../../actions/ui/requestDetail';
3234
class RequestDetailPage extends Component {
3335
componentDidMount() {
3436
this.props.refresh();
37+
if (config.costsApiUrlFormat) {
38+
const { requestId } = this.props.params;
39+
this.props.fetchCostsData(requestId);
40+
}
3541
}
3642

3743
componentWillReceiveProps(nextProps) {
@@ -63,6 +69,7 @@ class RequestDetailPage extends Component {
6369
initialPageNumber={Number(taskHistoryPage) || 1}
6470
/>
6571
)}
72+
{deleted || <CostsView requestId={requestId}/>}
6673
{deleted || <RequestUtilization requestId={requestId} />}
6774
{deleted || <DeployHistoryTable requestId={requestId} />}
6875
<RequestHistoryTable requestId={requestId} />
@@ -108,6 +115,7 @@ const mapDispatchToProps = (dispatch, ownProps) => {
108115
cancelRefresh: () => dispatch(
109116
RefreshActions.CancelAutoRefresh(`RequestDetailPage-${ownProps.index}`)
110117
),
118+
fetchCostsData: (requestId) => dispatch(FetchCostData.trigger(requestId, config.costsApiUrlFormat)),
111119
fetchRequest: (requestId) => dispatch(FetchRequest.trigger(requestId, true)),
112120
fetchTaskCleanups: () => dispatch(FetchTaskCleanups.trigger()),
113121
fetchTaskHistoryForRequest: (requestId, count, page) => dispatch(FetchTaskHistoryForRequest.trigger(requestId, count, page)),

0 commit comments

Comments
 (0)