}
```
+#### VOCABULARY_URL
+Local path, or any URL that allows cross-origin requests. See
+[Vocabulary JSON file example](public/vocabulary-example.json).
+
### Licensing
Arvados is Free Software. See COPYING for information about Arvados Free
--- /dev/null
+{
+ "strict": false,
+ "tags": {
+ "fruit": {
+ "values": ["pineapple", "tomato", "orange", "banana", "advocado", "lemon", "apple", "peach", "strawberry"],
+ "strict": true
+ },
+ "animal": {
+ "values": ["human", "dog", "elephant", "eagle"],
+ "strict": false
+ },
+ "color": {
+ "values": ["yellow", "red", "magenta", "green"],
+ "strict": false
+ },
+ "text": {},
+ "category": {
+ "values": ["experimental", "development", "production"]
+ },
+ "comments": {},
+ "importance": {
+ "values": ["critical", "important", "low priority"]
+ },
+ "size": {
+ "values": ["x-small", "small", "medium", "large", "x-large"]
+ },
+ "country": {
+ "values": ["Afghanistan","Ă…land Islands","Albania","Algeria","American Samoa","AndorrA","Angola","Anguilla","Antarctica","Antigua and Barbuda","Argentina","Armenia","Aruba","Australia","Austria","Azerbaijan","Bahamas","Bahrain","Bangladesh","Barbados","Belarus","Belgium","Belize","Benin","Bermuda","Bhutan","Bolivia","Bosnia and Herzegovina","Botswana","Bouvet Island","Brazil","British Indian Ocean Territory","Brunei Darussalam","Bulgaria","Burkina Faso","Burundi","Cambodia","Cameroon","Canada","Cape Verde","Cayman Islands","Central African Republic","Chad","Chile","China","Christmas Island","Cocos (Keeling) Islands","Colombia","Comoros","Congo","Congo, The Democratic Republic of the","Cook Islands","Costa Rica","Cote D'Ivoire","Croatia","Cuba","Cyprus","Czech Republic","Denmark","Djibouti","Dominica","Dominican Republic","Ecuador","Egypt","El Salvador","Equatorial Guinea","Eritrea","Estonia","Ethiopia","Falkland Islands (Malvinas)","Faroe Islands","Fiji","Finland","France","French Guiana","French Polynesia","French Southern Territories","Gabon","Gambia","Georgia","Germany","Ghana","Gibraltar","Greece","Greenland","Grenada","Guadeloupe","Guam","Guatemala","Guernsey","Guinea","Guinea-Bissau","Guyana","Haiti","Heard Island and Mcdonald Islands","Holy See (Vatican City State)","Honduras","Hong Kong","Hungary","Iceland","India","Indonesia","Iran, Islamic Republic Of","Iraq","Ireland","Isle of Man","Israel","Italy","Jamaica","Japan","Jersey","Jordan","Kazakhstan","Kenya","Kiribati","Korea, Democratic People'S Republic of","Korea, Republic of","Kuwait","Kyrgyzstan","Lao People'S Democratic Republic","Latvia","Lebanon","Lesotho","Liberia","Libyan Arab Jamahiriya","Liechtenstein","Lithuania","Luxembourg","Macao","Macedonia, The Former Yugoslav Republic of","Madagascar","Malawi","Malaysia","Maldives","Mali","Malta","Marshall Islands","Martinique","Mauritania","Mauritius","Mayotte","Mexico","Micronesia, Federated States of","Moldova, Republic of","Monaco","Mongolia","Montserrat","Morocco","Mozambique","Myanmar","Namibia","Nauru","Nepal","Netherlands","Netherlands Antilles","New Caledonia","New Zealand","Nicaragua","Niger","Nigeria","Niue","Norfolk Island","Northern Mariana Islands","Norway","Oman","Pakistan","Palau","Palestinian Territory, Occupied","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Pitcairn","Poland","Portugal","Puerto Rico","Qatar","Reunion","Romania","Russian Federation","RWANDA","Saint Helena","Saint Kitts and Nevis","Saint Lucia","Saint Pierre and Miquelon","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia and Montenegro","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Georgia and the South Sandwich Islands","Spain","Sri Lanka","Sudan","Suriname","Svalbard and Jan Mayen","Swaziland","Sweden","Switzerland","Syrian Arab Republic","Taiwan, Province of China","Tajikistan","Tanzania, United Republic of","Thailand","Timor-Leste","Togo","Tokelau","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Turks and Caicos Islands","Tuvalu","Uganda","Ukraine","United Arab Emirates","United Kingdom","United States","United States Minor Outlying Islands","Uruguay","Uzbekistan","Vanuatu","Venezuela","Viet Nam","Virgin Islands, British","Virgin Islands, U.S.","Wallis and Futuna","Western Sahara","Yemen","Zambia","Zimbabwe"],
+ "strict": true
+ }
+ }
+}
\ No newline at end of file
.then(config => Axios
.get<Config>(getDiscoveryURL(config.API_HOST))
.then(response => ({
- config: {...response.data, vocabularyUrl: config.VOCABULARY_URL },
+ // TODO: After tests delete `|| '/vocabulary-example.json'`
+ config: {...response.data, vocabularyUrl: config.VOCABULARY_URL || '/vocabulary-example.json' },
apiHost: config.API_HOST,
})));
import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions';
import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set';
import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set';
+import { keepServiceActionSet } from '~/views-components/context-menu/action-sets/keep-service-action-set';
import { loadVocabulary } from '~/store/vocabulary/vocabulary-actions';
import { virtualMachineActionSet } from '~/views-components/context-menu/action-sets/virtual-machine-action-set';
addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
+addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
fetchConfig()
.then(({ config, apiHost }) => {
-// Copyright (C) The Arvados Authors. All rights reserved.\r
-//\r
-// SPDX-License-Identifier: AGPL-3.0\r
-\r
-import { Resource } from "./resource";\r
-\r
-export interface KeepResource extends Resource {\r
- serviceHost: string;\r
- servicePort: number;\r
- serviceSslFlag: boolean;\r
- serviceType: string;\r
-}\r
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from '~/models/resource';
+
+export interface KeepServiceResource extends Resource {
+ serviceHost: string;
+ servicePort: number;
+ serviceSslFlag: boolean;
+ serviceType: string;
+ readOnly: boolean;
+}
\ No newline at end of file
PROJECT = "arvados#group",
REPOSITORY = "arvados#repository",
SSH_KEY = "arvados#authorizedKeys",
+ KEEP_SERVICE = "arvados#keepService",
USER = "arvados#user",
VIRTUAL_MACHINE = "arvados#virtualMachine",
WORKFLOW = "arvados#workflow",
USER = 'tpzed',
VIRTUAL_MACHINE = '2x53u',
WORKFLOW = '7fd4e',
- SSH_KEY = 'fngyi'
+ SSH_KEY = 'fngyi',
+ KEEP_SERVICE = 'bi6l4'
}
export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}';
return ResourceKind.REPOSITORY;
case ResourceObjectType.SSH_KEY:
return ResourceKind.SSH_KEY;
+ case ResourceObjectType.KEEP_SERVICE:
+ return ResourceKind.KEEP_SERVICE;
default:
return undefined;
}
import { History, Location } from 'history';
import { RootStore } from '~/store/store';
-import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from './routes';
-import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadSshKeys, loadRepositories, loadVirtualMachines } from '~/store/workbench/workbench-actions';
+import {
+ matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute,
+ matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute,
+ matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute,
+ matchKeepServicesRoute
+} from './routes';
+import {
+ loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults,
+ loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog,
+ loadSshKeys, loadRepositories, loadVirtualMachines, loadKeepServices
+} from '~/store/workbench/workbench-actions';
import { navigateToRootProject } from '~/store/navigation/navigation-action';
-import { loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults } from '~//store/workbench/workbench-actions';
export const addRouteChangeHandlers = (history: History, store: RootStore) => {
const handler = handleLocationChange(store);
const trashMatch = matchTrashRoute(pathname);
const processMatch = matchProcessRoute(pathname);
const processLogMatch = matchProcessLogRoute(pathname);
- const repositoryMatch = matchRepositoriesRoute(pathname);
+ const repositoryMatch = matchRepositoriesRoute(pathname);
const searchResultsMatch = matchSearchResultsRoute(pathname);
const sharedWithMeMatch = matchSharedWithMeRoute(pathname);
const runProcessMatch = matchRunProcessRoute(pathname);
const virtualMachineMatch = matchVirtualMachineRoute(pathname);
const workflowMatch = matchWorkflowRoute(pathname);
const sshKeysMatch = matchSshKeysRoute(pathname);
+ const keepServicesMatch = matchKeepServicesRoute(pathname);
if (projectMatch) {
store.dispatch(loadProject(projectMatch.params.id));
store.dispatch(loadRepositories);
} else if (sshKeysMatch) {
store.dispatch(loadSshKeys);
+ } else if (keepServicesMatch) {
+ store.dispatch(loadKeepServices);
}
};
VIRTUAL_MACHINES: '/virtual-machines',
WORKFLOWS: '/workflows',
SEARCH_RESULTS: '/search-results',
- SSH_KEYS: `/ssh-keys`
+ SSH_KEYS: `/ssh-keys`,
+ KEEP_SERVICES: `/keep-services`
};
export const getResourceUrl = (uuid: string) => {
export const matchSshKeysRoute = (route: string) =>
matchPath(route, { path: Routes.SSH_KEYS });
+
+export const matchKeepServicesRoute = (route: string) =>
+ matchPath(route, { path: Routes.KEEP_SERVICES });
}
public getIsAdmin(): boolean {
- return !!localStorage.getItem(USER_IS_ADMIN);
+ return localStorage.getItem(USER_IS_ADMIN) === 'true';
}
public getUser(): User | undefined {
\r
import { CommonResourceService } from "~/services/common-service/common-resource-service";\r
import { AxiosInstance } from "axios";\r
-import { KeepResource } from "~/models/keep";\r
+import { KeepServiceResource } from "~/models/keep-services";\r
import { ApiActions } from "~/services/api/api-actions";\r
\r
-export class KeepService extends CommonResourceService<KeepResource> {\r
+export class KeepService extends CommonResourceService<KeepServiceResource> {\r
constructor(serverApi: AxiosInstance, actions: ApiActions) {\r
super(serverApi, "keep_services", actions);\r
}\r
-}\r
+}
\ No newline at end of file
import { UserResource } from '~/models/user';
import { ListResults } from '~/services/common-service/common-resource-service';
import { LinkResource } from '~/models/link';
+import { KeepServiceResource } from '~/models/keep-services';
export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
enum ResourcePrefix {
REPOSITORIES = 'repositories',
AUTORIZED_KEYS = 'authorized_keys',
- VIRTUAL_MACHINES = 'virtual_machines'
+ VIRTUAL_MACHINES = 'virtual_machines',
+ KEEP_SERVICES = 'keep_services'
}
-type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData;
+enum KeepServiceData {
+ KEEP_SERVICE = 'keep_services',
+ CREATED_AT = 'created_at'
+}
+
+type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData;
type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix;
export const openAdvancedTabDialog = (uuid: string) =>
});
dispatch<any>(initAdvancedTabDialog(advanceDataVirtualMachine));
break;
+ case ResourceKind.KEEP_SERVICE:
+ const dataKeepService = getState().keepServices.find(it => it.uuid === uuid);
+ const advanceDataKeepService: AdvancedTabDialogData = advancedTabData({
+ uuid,
+ metadata: '',
+ user: '',
+ apiResponseKind: keepServiceApiResponse,
+ data: dataKeepService,
+ resourceKind: KeepServiceData.KEEP_SERVICE,
+ resourcePrefix: ResourcePrefix.KEEP_SERVICES,
+ resourceKindProperty: KeepServiceData.CREATED_AT,
+ property: dataKeepService!.createdAt
+ });
+ dispatch<any>(initAdvancedTabDialog(advanceDataKeepService));
+ break;
default:
dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
}
const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => {
const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = apiResponse;
- const response = `"uuid": "${uuid}",
+ const response = `"hostname": ${stringify(hostname)},
+"uuid": "${uuid}",
"owner_uuid": "${ownerUuid}",
"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
"modified_at": ${stringify(modifiedAt)},
-"hostname": ${stringify(hostname)},
+"modified_at": ${stringify(modifiedAt)},
"created_at": "${createdAt}"`;
+
+ return response;
+};
+
+const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
+ const {
+ uuid, readOnly, serviceHost, servicePort, serviceSslFlag, serviceType,
+ ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid
+ } = apiResponse;
+ const response = `"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"service_host": "${serviceHost}",
+"service_port": "${servicePort}",
+"service_ssl_flag": "${stringify(serviceSslFlag)}",
+"service_type": "${serviceType}",
+"created_at": "${createdAt}",
+"read_only": "${stringify(readOnly)}"`;
+
return response;
};
\ No newline at end of file
localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
localStorage.setItem(USER_UUID_KEY, "uuid");
localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
- localStorage.setItem(USER_IS_ADMIN, JSON.stringify(false));
+ localStorage.setItem(USER_IS_ADMIN, JSON.stringify("false"));
store.dispatch(initAuth());
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
- isAdmin: false
+ isAdmin: true
}
});
});
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
- isAdmin: true
+ isAdmin: false
};
const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
expect(state).toEqual({
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
- isAdmin: true
+ isAdmin: false
};
const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
lastName: "Doe",
uuid: "uuid",
ownerUuid: "ownerUuid",
- isAdmin: true
+ isAdmin: false
}
});
});
import { RepositoryResource } from '~/models/repositories';
import { SshKeyResource } from '~/models/ssh-key';
import { VirtualMachinesResource } from '~/models/virtual-machines';
+import { KeepServiceResource } from '~/models/keep-services';
export const contextMenuActions = unionize({
OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
isTrashed?: boolean;
index?: number
};
-export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) =>
- event.nativeEvent.detail === 0;
+
+export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
+
export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
(dispatch: Dispatch) => {
event.preventDefault();
}));
};
+export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) =>
+ (dispatch: Dispatch) => {
+ dispatch<any>(openContextMenu(event, {
+ name: '',
+ uuid: keepService.uuid,
+ ownerUuid: keepService.ownerUuid,
+ kind: ResourceKind.KEEP_SERVICE,
+ menuKind: ContextMenuKind.KEEP_SERVICE
+ }));
+ };
+
export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
(dispatch: Dispatch, getState: () => RootState) => {
const res = getResource<UserResource>(projectUuid)(getState().resources);
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+import { RootState } from '~/store/store';
+import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { ServiceRepository } from "~/services/services";
+import { KeepServiceResource } from '~/models/keep-services';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { navigateToRootProject } from '~/store/navigation/navigation-action';
+
+export const keepServicesActions = unionize({
+ SET_KEEP_SERVICES: ofType<KeepServiceResource[]>(),
+ REMOVE_KEEP_SERVICE: ofType<string>()
+});
+
+export type KeepServicesActions = UnionOf<typeof keepServicesActions>;
+
+export const KEEP_SERVICE_REMOVE_DIALOG = 'keepServiceRemoveDialog';
+export const KEEP_SERVICE_ATTRIBUTES_DIALOG = 'keepServiceAttributesDialog';
+
+export const loadKeepServicesPanel = () =>
+ async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+ const user = getState().auth.user;
+ if(user && user.isAdmin) {
+ try {
+ dispatch(setBreadcrumbs([{ label: 'Keep Services' }]));
+ const response = await services.keepService.list();
+ dispatch(keepServicesActions.SET_KEEP_SERVICES(response.items));
+ } catch (e) {
+ return;
+ }
+ } else {
+ dispatch(navigateToRootProject);
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: "You don't have permissions to view this page", hideDuration: 2000 }));
+ }
+ };
+
+export const openKeepServiceAttributesDialog = (uuid: string) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ const keepService = getState().keepServices.find(it => it.uuid === uuid);
+ dispatch(dialogActions.OPEN_DIALOG({ id: KEEP_SERVICE_ATTRIBUTES_DIALOG, data: { keepService } }));
+ };
+
+export const openKeepServiceRemoveDialog = (uuid: string) =>
+ (dispatch: Dispatch, getState: () => RootState) => {
+ dispatch(dialogActions.OPEN_DIALOG({
+ id: KEEP_SERVICE_REMOVE_DIALOG,
+ data: {
+ title: 'Remove keep service',
+ text: 'Are you sure you want to remove this keep service?',
+ confirmButtonLabel: 'Remove',
+ uuid
+ }
+ }));
+ };
+
+export const removeKeepService = (uuid: string) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+ try {
+ await services.keepService.delete(uuid);
+ dispatch(keepServicesActions.REMOVE_KEEP_SERVICE(uuid));
+ dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Keep service has been successfully removed.', hideDuration: 2000 }));
+ } catch (e) {
+ return;
+ }
+ };
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { keepServicesActions, KeepServicesActions } from '~/store/keep-services/keep-services-actions';
+import { KeepServiceResource } from '~/models/keep-services';
+
+export type KeepSericesState = KeepServiceResource[];
+
+const initialState: KeepSericesState = [];
+
+export const keepServicesReducer = (state: KeepSericesState = initialState, action: KeepServicesActions): KeepSericesState =>
+ keepServicesActions.match(action, {
+ SET_KEEP_SERVICES: items => items,
+ REMOVE_KEEP_SERVICE: (uuid: string) => state.filter((keepService) => keepService.uuid !== uuid),
+ default: () => state
+ });
\ No newline at end of file
export const navigateToRepositories = push(Routes.REPOSITORIES);
export const navigateToSshKeys= push(Routes.SSH_KEYS);
+
+export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
\ No newline at end of file
import { resourcesDataReducer } from "~/store/resources-data/resources-data-reducer";
import { virtualMachinesReducer } from "~/store/virtual-machines/virtual-machines-reducer";
import { repositoriesReducer } from '~/store/repositories/repositories-reducer';
+import { keepServicesReducer } from '~/store/keep-services/keep-services-reducer';
const composeEnhancers =
(process.env.NODE_ENV === 'development' &&
appInfo: appInfoReducer,
searchBar: searchBarReducer,
virtualMachines: virtualMachinesReducer,
- repositories: repositoriesReducer
+ repositories: repositoriesReducer,
+ keepServices: keepServicesReducer
});
import { searchResultsPanelColumns } from '~/views/search-results-panel/search-results-panel-view';
import { loadVirtualMachinesPanel } from '~/store/virtual-machines/virtual-machines-actions';
import { loadRepositoriesPanel } from '~/store/repositories/repositories-actions';
+import { loadKeepServicesPanel } from '~/store/keep-services/keep-services-actions';
export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
await dispatch(loadSshKeysPanel());
});
+export const loadKeepServices = handleFirstTimeLoad(
+ async (dispatch: Dispatch<any>) => {
+ await dispatch(loadKeepServicesPanel());
+ });
+
const finishLoadingProject = (project: GroupContentsResource | string) =>
async (dispatch: Dispatch<any>) => {
const uuid = typeof project === 'string' ? project : project.uuid;
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { openKeepServiceAttributesDialog, openKeepServiceRemoveDialog } from '~/store/keep-services/keep-services-actions';
+import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab';
+import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon";
+
+export const keepServiceActionSet: ContextMenuActionSet = [[{
+ name: "Attributes",
+ icon: AttributesIcon,
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(openKeepServiceAttributesDialog(uuid));
+ }
+}, {
+ name: "Advanced",
+ icon: AdvancedIcon,
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(openAdvancedTabDialog(uuid));
+ }
+}, {
+ name: "Remove",
+ icon: RemoveIcon,
+ execute: (dispatch, { uuid }) => {
+ dispatch<any>(openKeepServiceRemoveDialog(uuid));
+ }
+}]];
PROCESS_LOGS = "ProcessLogs",
REPOSITORY = "Repository",
SSH_KEY = "SshKey",
- VIRTUAL_MACHINE = "VirtualMachine"
+ VIRTUAL_MACHINE = "VirtualMachine",
+ KEEP_SERVICE = "KeepService"
}
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { compose } from 'redux';
+import {
+ withStyles, Dialog, DialogTitle, DialogContent, DialogActions,
+ Button, StyleRulesCallback, WithStyles, Grid
+} from '@material-ui/core';
+import { WithDialogProps, withDialog } from "~/store/dialog/with-dialog";
+import { KEEP_SERVICE_ATTRIBUTES_DIALOG } from '~/store/keep-services/keep-services-actions';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { KeepServiceResource } from '~/models/keep-services';
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ root: {
+ fontSize: '0.875rem',
+ '& div:nth-child(odd)': {
+ textAlign: 'right',
+ color: theme.palette.grey["500"]
+ }
+ }
+});
+
+interface AttributesKeepServiceDialogDataProps {
+ keepService: KeepServiceResource;
+}
+
+export const AttributesKeepServiceDialog = compose(
+ withDialog(KEEP_SERVICE_ATTRIBUTES_DIALOG),
+ withStyles(styles))(
+ ({ open, closeDialog, data, classes }: WithDialogProps<AttributesKeepServiceDialogDataProps> & WithStyles<CssRules>) =>
+ <Dialog open={open} onClose={closeDialog} fullWidth maxWidth='sm'>
+ <DialogTitle>Attributes</DialogTitle>
+ <DialogContent>
+ {data.keepService && <Grid container direction="row" spacing={16} className={classes.root}>
+ <Grid item xs={5}>UUID</Grid>
+ <Grid item xs={7}>{data.keepService.uuid}</Grid>
+ <Grid item xs={5}>Read only</Grid>
+ <Grid item xs={7}>{JSON.stringify(data.keepService.readOnly)}</Grid>
+ <Grid item xs={5}>Service host</Grid>
+ <Grid item xs={7}>{data.keepService.serviceHost}</Grid>
+ <Grid item xs={5}>Service port</Grid>
+ <Grid item xs={7}>{data.keepService.servicePort}</Grid>
+ <Grid item xs={5}>Service SSL flag</Grid>
+ <Grid item xs={7}>{JSON.stringify(data.keepService.serviceSslFlag)}</Grid>
+ <Grid item xs={5}>Service type</Grid>
+ <Grid item xs={7}>{data.keepService.serviceType}</Grid>
+ <Grid item xs={5}>Owner uuid</Grid>
+ <Grid item xs={7}>{data.keepService.ownerUuid}</Grid>
+ <Grid item xs={5}>Created at</Grid>
+ <Grid item xs={7}>{data.keepService.createdAt}</Grid>
+ <Grid item xs={5}>Modified at</Grid>
+ <Grid item xs={7}>{data.keepService.modifiedAt}</Grid>
+ <Grid item xs={5}>Modified by user uuid</Grid>
+ <Grid item xs={7}>{data.keepService.modifiedByUserUuid}</Grid>
+ <Grid item xs={5}>Modified by client uuid</Grid>
+ <Grid item xs={7}>{data.keepService.modifiedByClientUuid}</Grid>
+ </Grid>}
+ </DialogContent>
+ <DialogActions>
+ <Button
+ variant='flat'
+ color='primary'
+ onClick={closeDialog}>
+ Close
+ </Button>
+ </DialogActions>
+ </Dialog>
+ );
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
+import { KEEP_SERVICE_REMOVE_DIALOG, removeKeepService } from '~/store/keep-services/keep-services-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+ onConfirm: () => {
+ props.closeDialog();
+ dispatch<any>(removeKeepService(props.data.uuid));
+ }
+});
+
+export const RemoveKeepServiceDialog = compose(
+ withDialog(KEEP_SERVICE_REMOVE_DIALOG),
+ connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
import { RootState } from "~/store/store";
import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
-import { navigateToSshKeys } from '~/store/navigation/navigation-action';
+import { navigateToSshKeys, navigateToKeepServices } from '~/store/navigation/navigation-action';
import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
interface AccountMenuProps {
<MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
<MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
<MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
+ { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
<MenuItem>My account</MenuItem>
<MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
</DropdownMenu>
import { Breadcrumbs } from "~/views-components/breadcrumbs/breadcrumbs";
import { connect } from 'react-redux';
import { RootState } from '~/store/store';
-import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from '~/routes/routes';
+import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute, matchKeepServicesRoute } from '~/routes/routes';
import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
interface MainContentBarProps {
buttonVisible: boolean;
}
-const isWorkflowPath = ({ router }: RootState) => {
+const isButtonVisible = ({ router }: RootState) => {
const pathname = router.location ? router.location.pathname : '';
- const match = matchWorkflowRoute(pathname);
- return !!match;
-};
-
-const isVirtualMachinePath = ({ router }: RootState) => {
- const pathname = router.location ? router.location.pathname : '';
- const match = matchVirtualMachineRoute(pathname);
- return !!match;
-};
-
-const isRepositoriesPath = ({ router }: RootState) => {
- const pathname = router.location ? router.location.pathname : '';
- const match = matchRepositoriesRoute(pathname);
- return !!match;
-};
-
-const isSshKeysPath = ({ router }: RootState) => {
- const pathname = router.location ? router.location.pathname : '';
- const match = matchSshKeysRoute(pathname);
- return !!match;
+ return !matchWorkflowRoute(pathname) && !matchVirtualMachineRoute(pathname) &&
+ !matchRepositoriesRoute(pathname) && !matchSshKeysRoute(pathname) && !matchKeepServicesRoute(pathname);
};
export const MainContentBar = connect((state: RootState) => ({
- buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state) && !isVirtualMachinePath(state)
+ buttonVisible: isButtonVisible(state)
}), {
onDetailsPanelToggle: toggleDetailsPanel
})((props: MainContentBarProps) =>
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { MoreOptionsIcon } from '~/components/icon/icon';
+import { KeepServiceResource } from '~/models/keep-services';
+
+type CssRules = 'root' | 'tableRow';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+ root: {
+ width: '100%',
+ overflow: 'auto'
+ },
+ tableRow: {
+ '& td, th': {
+ whiteSpace: 'nowrap'
+ }
+ }
+});
+
+export interface KeepServicePanelRootActionProps {
+ openRowOptions: (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) => void;
+}
+
+export interface KeepServicePanelRootDataProps {
+ keepServices: KeepServiceResource[];
+ hasKeepSerices: boolean;
+}
+
+type KeepServicePanelRootProps = KeepServicePanelRootActionProps & KeepServicePanelRootDataProps & WithStyles<CssRules>;
+
+export const KeepServicePanelRoot = withStyles(styles)(
+ ({ classes, hasKeepSerices, keepServices, openRowOptions }: KeepServicePanelRootProps) =>
+ <Card className={classes.root}>
+ <CardContent>
+ {hasKeepSerices && <Grid container direction="row">
+ <Grid item xs={12}>
+ <Table>
+ <TableHead>
+ <TableRow className={classes.tableRow}>
+ <TableCell>UUID</TableCell>
+ <TableCell>Read only</TableCell>
+ <TableCell>Service host</TableCell>
+ <TableCell>Service port</TableCell>
+ <TableCell>Service SSL flag</TableCell>
+ <TableCell>Service type</TableCell>
+ <TableCell />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {keepServices.map((keepService, index) =>
+ <TableRow key={index} className={classes.tableRow}>
+ <TableCell>{keepService.uuid}</TableCell>
+ <TableCell>
+ <Checkbox
+ disableRipple
+ color="primary"
+ checked={keepService.readOnly} />
+ </TableCell>
+ <TableCell>{keepService.serviceHost}</TableCell>
+ <TableCell>{keepService.servicePort}</TableCell>
+ <TableCell>
+ <Checkbox
+ disableRipple
+ color="primary"
+ checked={keepService.serviceSslFlag} />
+ </TableCell>
+ <TableCell>{keepService.serviceType}</TableCell>
+ <TableCell>
+ <Tooltip title="More options" disableFocusListener>
+ <IconButton onClick={event => openRowOptions(event, keepService)}>
+ <MoreOptionsIcon />
+ </IconButton>
+ </Tooltip>
+ </TableCell>
+ </TableRow>)}
+ </TableBody>
+ </Table>
+ </Grid>
+ </Grid>}
+ </CardContent>
+ </Card>
+);
\ No newline at end of file
--- /dev/null
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { } from '~/store/keep-services/keep-services-actions';
+import {
+ KeepServicePanelRoot,
+ KeepServicePanelRootDataProps,
+ KeepServicePanelRootActionProps
+} from '~/views/keep-service-panel/keep-service-panel-root';
+import { openKeepServiceContextMenu } from '~/store/context-menu/context-menu-actions';
+
+const mapStateToProps = (state: RootState): KeepServicePanelRootDataProps => {
+ return {
+ keepServices: state.keepServices,
+ hasKeepSerices: state.keepServices.length > 0
+ };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): KeepServicePanelRootActionProps => ({
+ openRowOptions: (event, keepService) => {
+ dispatch<any>(openKeepServiceContextMenu(event, keepService));
+ }
+});
+
+export const KeepServicePanel = connect(mapStateToProps, mapDispatchToProps)(KeepServicePanelRoot);
\ No newline at end of file
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import { BooleanCommandInputParameter } from '~/models/workflow';
import { Field } from 'redux-form';
import { Switch } from '@material-ui/core';
name={input.id}
commandInput={input}
component={BooleanInputComponent}
- normalize={(value, prevValue) => !prevValue}
+ normalize={normalize}
/>;
+const normalize = (_: any, prevValue: boolean) => !prevValue;
+
const BooleanInputComponent = (props: GenericInputProps) =>
<GenericInput
component={Input}
{...props} />;
-const Input = (props: GenericInputProps) =>
+const Input = ({ input, commandInput }: GenericInputProps) =>
<Switch
color='primary'
- checked={props.input.value}
- onChange={() => props.input.onChange(props.input.value)}
- disabled={props.commandInput.disabled} />;
\ No newline at end of file
+ checked={input.value}
+ onChange={handleChange(input.onChange, input.value)}
+ disabled={commandInput.disabled} />;
+
+const handleChange = memoize(
+ (onChange: (value: string) => void, value: string) => () => onChange(value)
+);
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { memoize } from 'lodash/fp';
+import { Field } from 'redux-form';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
import {
isRequiredInput,
DirectoryCommandInputParameter,
CWLType,
Directory
} from '~/models/workflow';
-import { Field } from 'redux-form';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
import { GenericInputProps, GenericInput } from './generic-input';
import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
-import { connect, DispatchProp } from 'react-redux';
import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
import { TreeItem } from '~/components/tree/tree';
import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
import { CollectionResource } from '~/models/collection';
import { ResourceKind } from '~/models/resource';
-import { ERROR_MESSAGE } from '../../../validators/require';
+import { ERROR_MESSAGE } from '~/validators/require';
export interface DirectoryInputProps {
input: DirectoryCommandInputParameter;
name={input.id}
commandInput={input}
component={DirectoryInputComponent}
- format={(value?: Directory) => value ? value.basename : ''}
- parse={(directory: CollectionResource): Directory => ({
- class: CWLType.DIRECTORY,
- location: `keep:${directory.portableDataHash}`,
- basename: directory.name,
- })}
- validate={[
- isRequiredInput(input)
- ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
- : () => undefined,
- ]} />;
-
+ format={format}
+ parse={parse}
+ validate={getValidation(input)} />;
+
+const format = (value?: Directory) => value ? value.basename : '';
+
+const parse = (directory: CollectionResource): Directory => ({
+ class: CWLType.DIRECTORY,
+ location: `keep:${directory.portableDataHash}`,
+ basename: directory.name,
+});
+
+const getValidation = memoize(
+ (input: DirectoryCommandInputParameter) => ([
+ isRequiredInput(input)
+ ? (directory?: Directory) => directory ? undefined : ERROR_MESSAGE
+ : () => undefined,
+ ])
+);
interface DirectoryInputComponentState {
open: boolean;
this.props.input.onChange(this.state.directory);
}
- setDirectory = (event: React.MouseEvent<HTMLElement>, { data }: TreeItem<ProjectsTreePickerItem>, pickerId: string) => {
+ setDirectory = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
if ('kind' in data && data.kind === ResourceKind.COLLECTION) {
this.setState({ directory: data });
} else {
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
-import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow';
import { Field } from 'redux-form';
import { Select, MenuItem } from '@material-ui/core';
+import { EnumCommandInputParameter, CommandInputEnumSchema } from '~/models/workflow';
import { GenericInputProps, GenericInput } from './generic-input';
export interface EnumInputProps {
onChange={props.input.onChange}
disabled={props.commandInput.disabled} >
{type.symbols.map(symbol =>
- <MenuItem key={symbol} value={symbol.split('/').pop()}>
- {symbol.split('/').pop()}
+ <MenuItem key={symbol} value={extractValue(symbol)}>
+ {extractValue(symbol)}
</MenuItem>)}
</Select>;
-};
\ No newline at end of file
+};
+
+/**
+ * Values in workflow definition have an absolute form, for example:
+ *
+ * ```#input_collector.cwl/enum_type/Pathway table```
+ *
+ * We want a value that is in form accepted by backend.
+ * According to the example above, the correct value is:
+ *
+ * ```Pathway table```
+ */
+const extractValue = (symbol: string) => symbol.split('/').pop();
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import {
isRequiredInput,
FileCommandInputParameter,
name={input.id}
commandInput={input}
component={FileInputComponent}
- format={(value?: File) => value ? value.basename : ''}
- parse={(file: CollectionFile): File => ({
- class: CWLType.FILE,
- location: `keep:${file.id}`,
- basename: file.name,
- })}
- validate={[
- isRequiredInput(input)
- ? (file?: File) => file ? undefined : ERROR_MESSAGE
- : () => undefined,
- ]} />;
+ format={format}
+ parse={parse}
+ validate={getValidation(input)} />;
+const format = (value?: File) => value ? value.basename : '';
+
+const parse = (file: CollectionFile): File => ({
+ class: CWLType.FILE,
+ location: `keep:${file.id}`,
+ basename: file.name,
+});
+
+const getValidation = memoize(
+ (input: FileCommandInputParameter) => ([
+ isRequiredInput(input)
+ ? (file?: File) => file ? undefined : ERROR_MESSAGE
+ : () => undefined,
+ ]));
interface FileInputComponentState {
open: boolean;
this.props.input.onChange(this.state.file);
}
- setFile = (event: React.MouseEvent<HTMLElement>, { data }: TreeItem<ProjectsTreePickerItem>, pickerId: string) => {
+ setFile = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
if ('type' in data && data.type === CollectionFileType.FILE) {
this.setState({ file: data });
} else {
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import { FloatCommandInputParameter, isRequiredInput } from '~/models/workflow';
import { Field } from 'redux-form';
import { isNumber } from '~/validators/is-number';
commandInput={input}
component={Input}
parse={parseFloat}
- format={value => isNaN(value) ? '' : JSON.stringify(value)}
- validate={[
- isRequiredInput(input)
- ? isNumber
- : () => undefined,]} />;
+ format={format}
+ validate={getValidation(input)} />;
+
+const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+ (input: FloatCommandInputParameter) => ([
+ isRequiredInput(input)
+ ? isNumber
+ : () => undefined,])
+);
const Input = (props: GenericInputProps) =>
<GenericInput
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import { IntCommandInputParameter, isRequiredInput } from '~/models/workflow';
import { Field } from 'redux-form';
import { isInteger } from '~/validators/is-integer';
name={input.id}
commandInput={input}
component={InputComponent}
- parse={value => parseInt(value, 10)}
- format={value => isNaN(value) ? '' : JSON.stringify(value)}
- validate={[
- isRequiredInput(input)
- ? isInteger
- : () => undefined,
- ]} />;
+ parse={parse}
+ format={format}
+ validate={getValidation(input)} />;
+
+const parse = (value: any) => parseInt(value, 10);
+
+const format = (value: any) => isNaN(value) ? '' : JSON.stringify(value);
+
+const getValidation = memoize(
+ (input: IntCommandInputParameter) => ([
+ isRequiredInput(input)
+ ? isInteger
+ : () => undefined,
+ ]));
const InputComponent = (props: GenericInputProps) =>
<GenericInput
// SPDX-License-Identifier: AGPL-3.0
import * as React from 'react';
+import { memoize } from 'lodash/fp';
import { isRequiredInput, StringCommandInputParameter } from '~/models/workflow';
import { Field } from 'redux-form';
import { require } from '~/validators/require';
name={input.id}
commandInput={input}
component={StringInputComponent}
- validate={[
- isRequiredInput(input)
- ? require
- : () => undefined,
- ]} />;
+ validate={getValidation(input)} />;
+
+const getValidation = memoize(
+ (input: StringCommandInputParameter) => ([
+ isRequiredInput(input)
+ ? require
+ : () => undefined,
+ ]));
const StringInputComponent = (props: GenericInputProps) =>
<GenericInput
import { VirtualMachinePanel } from '~/views/virtual-machine-panel/virtual-machine-panel';
import { ProjectPropertiesDialog } from '~/views-components/project-properties-dialog/project-properties-dialog';
import { RepositoriesPanel } from '~/views/repositories-panel/repositories-panel';
+import { KeepServicePanel } from '~/views/keep-service-panel/keep-service-panel';
import { RepositoriesSampleGitDialog } from '~/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
import { RepositoryAttributesDialog } from '~/views-components/repository-attributes-dialog/repository-attributes-dialog';
import { CreateRepositoryDialog } from '~/views-components/dialog-forms/create-repository-dialog';
import { RemoveRepositoryDialog } from '~/views-components/repository-remove-dialog/repository-remove-dialog';
import { CreateSshKeyDialog } from '~/views-components/dialog-forms/create-ssh-key-dialog';
import { PublicKeyDialog } from '~/views-components/ssh-keys-dialog/public-key-dialog';
+import { RemoveKeepServiceDialog } from '~/views-components/keep-services-dialog/remove-dialog';
import { RemoveSshKeyDialog } from '~/views-components/ssh-keys-dialog/remove-dialog';
+import { AttributesKeepServiceDialog } from '~/views-components/keep-services-dialog/attributes-dialog';
import { AttributesSshKeyDialog } from '~/views-components/ssh-keys-dialog/attributes-dialog';
import { VirtualMachineAttributesDialog } from '~/views-components/virtual-machines-dialog/attributes-dialog';
import { RemoveVirtualMachineDialog } from '~/views-components/virtual-machines-dialog/remove-dialog';
<Route path={Routes.VIRTUAL_MACHINES} component={VirtualMachinePanel} />
<Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
<Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
+ <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
</Switch>
</Grid>
</Grid>
<DetailsPanel />
</Grid>
<AdvancedTabDialog />
+ <AttributesKeepServiceDialog />
<AttributesSshKeyDialog />
<ChangeWorkflowDialog />
<ContextMenu />
<ProcessCommandDialog />
<ProcessInputDialog />
<ProjectPropertiesDialog />
+ <RemoveKeepServiceDialog />
<RemoveProcessDialog />
<RemoveRepositoryDialog />
<RemoveSshKeyDialog />