From 1f0c3963900611a0e769e6fd88cfcb2ec11ddfd8 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 30 May 2022 10:36:21 +0100 Subject: [PATCH 1/2] initial swa deploy --- .devcontainer/Dockerfile | 7 +++++-- templates/core/terraform/main.tf | 6 ++++++ templates/core/terraform/outputs.tf | 4 ++++ .../user_resources/guacamole-dev-vm/template_schema.json | 4 ++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 46ccf7612f..4f03faca49 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -18,8 +18,8 @@ RUN bash /tmp/non-root-user.sh "${USERNAME}" "${USER_UID}" "${USER_GID}" # Set env for tracking that we're running in a devcontainer ENV DEVCONTAINER=true -# [Option] Install Node.js -ARG INSTALL_NODE="false" +# [Option] Install Node.js for GH actions tests and UI +ARG INSTALL_NODE="true" ARG NODE_VERSION="lts/*" RUN if [ "${INSTALL_NODE}" = "true" ]; then su $USERNAME -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi @@ -95,3 +95,6 @@ RUN echo "export HISTFILE=$HOME/commandhistory/.bash_history" >> "$HOME/.bashrc" # Install github-cli COPY ./.devcontainer/scripts/gh.sh /tmp/ RUN if [ "${INTERACTIVE}" = "true" ]; then /tmp/gh.sh; fi + +# install SWA CLI +RUN if [ "${INSTALL_NODE}" = "true" ]; then npm install -g @azure/static-web-apps-cli; fi diff --git a/templates/core/terraform/main.tf b/templates/core/terraform/main.tf index 8012cc6e3a..0e0aa0d021 100644 --- a/templates/core/terraform/main.tf +++ b/templates/core/terraform/main.tf @@ -99,3 +99,9 @@ module "resource_processor_vmss_porter" { azurerm_key_vault_access_policy.deployer ] } + +resource "azurerm_static_site" "tre-ui" { + name = "${var.tre_id}-ui" + resource_group_name = azurerm_resource_group.core.name + location = var.location +} diff --git a/templates/core/terraform/outputs.tf b/templates/core/terraform/outputs.tf index 00ff260525..e6d6622909 100644 --- a/templates/core/terraform/outputs.tf +++ b/templates/core/terraform/outputs.tf @@ -76,3 +76,7 @@ output "terraform_state_container_name" { output "registry_server" { value = var.docker_registry_server } + +output "ui_api_key" { + value = azurerm_static_site.tre-ui.api_key +} diff --git a/templates/workspace_services/guacamole/user_resources/guacamole-dev-vm/template_schema.json b/templates/workspace_services/guacamole/user_resources/guacamole-dev-vm/template_schema.json index 535bcb14b3..e2eedfe1db 100644 --- a/templates/workspace_services/guacamole/user_resources/guacamole-dev-vm/template_schema.json +++ b/templates/workspace_services/guacamole/user_resources/guacamole-dev-vm/template_schema.json @@ -37,7 +37,7 @@ "stepId": "6d2d7eb7-984e-4330-bd3c-c7ec98658402", "stepTitle": "Update the firewall the first time", "resourceTemplateName": "tre-shared-service-firewall", - "resourceType": "shared_service", + "resourceType": "shared-service", "resourceAction": "upgrade", "properties": [ { @@ -53,7 +53,7 @@ "stepId": "2fe8a6a7-2c27-4c49-8773-127df8a48b4e", "stepTitle": "Update the firewall the second time", "resourceTemplateName": "tre-shared-service-firewall", - "resourceType": "shared_service", + "resourceType": "shared-service", "resourceAction": "upgrade", "properties": [ { From 73cfa23ae6889564148699b24f66fc3529a1ed33 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 6 Jun 2022 16:33:29 +0100 Subject: [PATCH 2/2] status implemented --- api_app/_version.py | 2 +- api_app/models/domain/operation.py | 7 + api_app/models/domain/resource.py | 3 +- .../service_bus/deployment_status_update.py | 5 + ui/app/src/App.scss | 21 ++ .../CreateUpdateResource.tsx | 2 +- .../CreateUpdateResource/ResourceForm.tsx | 182 ++++++++++-------- ui/app/src/components/shared/ResourceCard.tsx | 30 +-- .../components/shared/ResourceContextMenu.tsx | 3 +- .../src/components/shared/ResourceHeader.tsx | 25 ++- .../src/components/shared/ResourceHistory.tsx | 4 +- .../components/shared/SharedServiceItem.tsx | 4 +- ui/app/src/components/shared/StatusBadge.tsx | 20 ++ .../workspaces/UserResourceItem.tsx | 10 +- .../components/workspaces/WorkspaceItem.tsx | 22 +-- .../workspaces/WorkspaceLeftNav.tsx | 6 +- .../workspaces/WorkspaceProvider.tsx | 13 +- .../workspaces/WorkspaceServiceItem.tsx | 122 +++++++----- .../workspaces/WorkspaceServices.tsx | 3 +- ui/app/src/hooks/useComponentManager.ts | 16 +- ui/app/src/models/operation.ts | 9 +- ui/app/src/models/resource.ts | 1 + 22 files changed, 315 insertions(+), 195 deletions(-) create mode 100644 ui/app/src/components/shared/StatusBadge.tsx diff --git a/api_app/_version.py b/api_app/_version.py index d7b30e1210..8879c6c772 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.3.6" +__version__ = "0.3.7" diff --git a/api_app/models/domain/operation.py b/api_app/models/domain/operation.py index 94b59164a4..e741ab49a2 100644 --- a/api_app/models/domain/operation.py +++ b/api_app/models/domain/operation.py @@ -58,6 +58,13 @@ def is_failure(self) -> bool: Status.Failed ) + def is_action(self) -> bool: + return self.status in ( + Status.ActionSucceeded, + Status.ActionFailed, + Status.InvokingAction + ) + class Operation(AzureTREModel): """ diff --git a/api_app/models/domain/resource.py b/api_app/models/domain/resource.py index a393ee719f..416d53fdbc 100644 --- a/api_app/models/domain/resource.py +++ b/api_app/models/domain/resource.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List +from typing import List, Optional from pydantic import Field from models.domain.azuretremodel import AzureTREModel from models.domain.request_action import RequestAction @@ -38,6 +38,7 @@ class Resource(AzureTREModel): isActive: bool = True # When False, hides resource document from list views isEnabled: bool = True # Must be set before a resource can be deleted resourceType: ResourceType + deploymentStatus: Optional[str] = Field(title="Deployment Status", description="Overall deployment status of the resource") etag: str = Field(title="_etag", description="eTag of the document", alias="_etag") resourcePath: str = "" resourceVersion: int = 0 diff --git a/api_app/service_bus/deployment_status_update.py b/api_app/service_bus/deployment_status_update.py index 1d9713dcec..a58dacb659 100644 --- a/api_app/service_bus/deployment_status_update.py +++ b/api_app/service_bus/deployment_status_update.py @@ -157,6 +157,11 @@ async def update_status_in_database(resource_repo: ResourceRepository, operation # save the operation operations_repo.update_item(operation) + # copy the step status to the resource item, for convenience + resource = resource_repo.get_resource_by_id(uuid.UUID(step_to_update.resourceId)) + resource.deploymentStatus = step_to_update.status + resource_repo.update_item(resource) + # if the step failed, or this queue message is an intermediary ("now deploying..."), return here. if not step_to_update.is_success(): return True diff --git a/ui/app/src/App.scss b/ui/app/src/App.scss index 036de06e11..9dcd926e4b 100644 --- a/ui/app/src/App.scss +++ b/ui/app/src/App.scss @@ -146,3 +146,24 @@ ul.tre-notifications-steps-list li { input[readonly]{ background-color:#efefef; } + +.tre-badge{ + border-radius:4px; + background-color: #efefef; + padding:2px 6px; + text-transform: capitalize; + display:inline-block; + font-size:12px; +} +.tre-badge-in-progress{ + background-color: #ce7b00; + color: #fff; +} +.tre-badge-failed{ + background-color: #990000; + color: #fff; +} +.tre-badge-success{ + background-color: #006600; + color: #fff; +} diff --git a/ui/app/src/components/shared/CreateUpdateResource/CreateUpdateResource.tsx b/ui/app/src/components/shared/CreateUpdateResource/CreateUpdateResource.tsx index 17140e9ba7..38e14d60d1 100644 --- a/ui/app/src/components/shared/CreateUpdateResource/CreateUpdateResource.tsx +++ b/ui/app/src/components/shared/CreateUpdateResource/CreateUpdateResource.tsx @@ -139,7 +139,7 @@ export const CreateUpdateResource: React.FunctionComponent

{props.updateResource?.id ? 'Updating' : 'Creating'} {props.resourceType}...

Check the notifications panel for deployment progress.

- navigate(deployOperation.resourcePath)} /> + {navigate(deployOperation.resourcePath); props.onClose();}} /> ; break; } diff --git a/ui/app/src/components/shared/CreateUpdateResource/ResourceForm.tsx b/ui/app/src/components/shared/CreateUpdateResource/ResourceForm.tsx index 81887facc9..76bcafe7b4 100644 --- a/ui/app/src/components/shared/CreateUpdateResource/ResourceForm.tsx +++ b/ui/app/src/components/shared/CreateUpdateResource/ResourceForm.tsx @@ -5,102 +5,114 @@ import { HttpMethod, ResultType, useAuthApiCall } from "../../../hooks/useAuthAp import Form from "@rjsf/fluent-ui"; import { Operation } from "../../../models/operation"; import { Resource } from "../../../models/resource"; +import { ResourceType } from "../../../models/resourceType"; interface ResourceFormProps { - templateName: string, - templatePath: string, - resourcePath: string, - updateResource?: Resource, - onCreateResource: (operation: Operation) => void, - workspaceClientId?: string + templateName: string, + templatePath: string, + resourcePath: string, + updateResource?: Resource, + onCreateResource: (operation: Operation) => void, + workspaceClientId?: string } export const ResourceForm: React.FunctionComponent = (props: ResourceFormProps) => { - const [template, setTemplate] = useState(null); - const [formData, setFormData] = useState({}); - const [loading, setLoading] = useState(LoadingState.Loading as LoadingState); - const [deployError, setDeployError] = useState(false); - const apiCall = useAuthApiCall(); + const [template, setTemplate] = useState(null); + const [formData, setFormData] = useState({}); + const [loading, setLoading] = useState(LoadingState.Loading as LoadingState); + const [deployError, setDeployError] = useState(false); + const [sendingData, setSendingData] = useState(false); + const apiCall = useAuthApiCall(); - useEffect(() => { - const getFullTemplate = async () => { - try { - // Get the full resource template containing the required parameters - const templateResponse = await apiCall(props.updateResource ? `${props.templatePath}?is_update=true` : props.templatePath, HttpMethod.Get); + useEffect(() => { + const getFullTemplate = async () => { + try { + // Get the full resource template containing the required parameters + const templateResponse = await apiCall(props.updateResource ? `${props.templatePath}?is_update=true` : props.templatePath, HttpMethod.Get); - // if it's an update, populate the form with the props that are available in the template - if (props.updateResource) { - let d:any = {}; - for(let prop in templateResponse.properties){ - d[prop] = props.updateResource?.properties[prop]; - } - setFormData(d); - } - - setTemplate(templateResponse); - setLoading(LoadingState.Ok); - } catch { - setLoading(LoadingState.Error); - } - }; - - // Fetch full resource template only if not in state - if (!template) { - getFullTemplate(); - } - }, [apiCall, props.templatePath, template, props.updateResource]); - - const createUpdateResource = async (formData: any) => { - setDeployError(false); - let response; - if(props.updateResource) { - // only send the properties we're allowed to send - let d:any = {} - for(let prop in template.properties) { - if (!template.properties[prop].readOnly) d[prop] = formData[prop]; + // if it's an update, populate the form with the props that are available in the template + if (props.updateResource) { + let d: any = {}; + for (let prop in templateResponse.properties) { + d[prop] = props.updateResource?.properties[prop]; } - console.log("patching d", d); - response = await apiCall(props.updateResource.resourcePath, HttpMethod.Patch, props.workspaceClientId, { properties: d }, ResultType.JSON, undefined, undefined, props.updateResource._etag); - } else { - const resource = { templateName: props.templateName, properties: formData }; - console.log(resource); - response = await apiCall(props.resourcePath, HttpMethod.Post, props.workspaceClientId, resource, ResultType.JSON); + setFormData(d); } - if (response) { - props.onCreateResource(response.operation); - } else { - setDeployError(true); - } + setTemplate(templateResponse); + setLoading(LoadingState.Ok); + } catch { + setLoading(LoadingState.Error); + } + }; + + // Fetch full resource template only if not in state + if (!template) { + getFullTemplate(); } + }, [apiCall, props.templatePath, template, props.updateResource]); - switch (loading) { - case LoadingState.Ok: - return ( - template ?
-
createUpdateResource(e.formData)}/> - { - deployError ? -

The API returned an error. Check the console for details or retry.

-
: null - } -
: null - ) - case LoadingState.Error: - return ( - -

Error retrieving template

-

There was an error retrieving the full resource template. Please see the browser console for details.

-
- ); - default: - return ( -
- -
- ) + const createUpdateResource = async (formData: any) => { + setDeployError(false); + setSendingData(true); + let response; + if (props.updateResource) { + // only send the properties we're allowed to send + let d: any = {} + for (let prop in template.properties) { + if (!template.properties[prop].readOnly) d[prop] = formData[prop]; + } + console.log("patching resource", d); + let wsAuth = props.updateResource.resourceType === ResourceType.WorkspaceService || props.updateResource.resourceType === ResourceType.UserResource; + response = await apiCall(props.updateResource.resourcePath, HttpMethod.Patch, wsAuth ? props.workspaceClientId : undefined, { properties: d }, ResultType.JSON, undefined, undefined, props.updateResource._etag); + } else { + const resource = { templateName: props.templateName, properties: formData }; + console.log(resource); + response = await apiCall(props.resourcePath, HttpMethod.Post, props.workspaceClientId, resource, ResultType.JSON); } + + setSendingData(false); + if (response) { + props.onCreateResource(response.operation); + } else { + setDeployError(true); + } + } + + switch (loading) { + case LoadingState.Ok: + return ( + template && +
+ { + sendingData ? + + : + createUpdateResource(e.formData)} /> + } + { + deployError && + +

The API returned an error. Check the console for details or retry.

+
+ } +
+ ) + case LoadingState.Error: + return ( + +

Error retrieving template

+

There was an error retrieving the full resource template. Please see the browser console for details.

+
+ ); + default: + return ( +
+ +
+ ) + } } diff --git a/ui/app/src/components/shared/ResourceCard.tsx b/ui/app/src/components/shared/ResourceCard.tsx index 361c4a9ef1..a49c6e04a7 100644 --- a/ui/app/src/components/shared/ResourceCard.tsx +++ b/ui/app/src/components/shared/ResourceCard.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'; import moment from 'moment'; import { ResourceContextMenu } from './ResourceContextMenu'; import { useComponentManager } from '../../hooks/useComponentManager'; +import { StatusBadge } from './StatusBadge'; interface ResourceCardProps { resource: Resource, @@ -17,10 +18,10 @@ interface ResourceCardProps { export const ResourceCard: React.FunctionComponent = (props: ResourceCardProps) => { const [loading] = useState(false); const [showInfo, setShowInfo] = useState(false); - const componentAction = useComponentManager( + const latestUpdate = useComponentManager( props.resource, - (r: Resource) => {props.onUpdate(r)}, - (r: Resource) => {props.onDelete(r)} + (r: Resource) => { props.onUpdate(r) }, + (r: Resource) => { props.onDelete(r) } ); let connectUri = props.resource.properties && props.resource.properties.connection_uri; @@ -57,7 +58,7 @@ export const ResourceCard: React.FunctionComponent = (props: + componentAction={latestUpdate.componentAction} /> @@ -72,12 +73,19 @@ export const ResourceCard: React.FunctionComponent = (props: } - { - componentAction === ComponentAction.Lock && - - } + + + { + latestUpdate.componentAction === ComponentAction.Lock && + + } + + + + + } @@ -120,7 +128,7 @@ const cardStyles: React.CSSProperties = { width: '100%', borderRadius: '2px', border: '1px #ccc solid', -// boxShadow: '1px 0px 4px 0px #dddddd' + // boxShadow: '1px 0px 4px 0px #dddddd' } const headerStyles: React.CSSProperties = { diff --git a/ui/app/src/components/shared/ResourceContextMenu.tsx b/ui/app/src/components/shared/ResourceContextMenu.tsx index de0340f5ce..6d8da36ca0 100644 --- a/ui/app/src/components/shared/ResourceContextMenu.tsx +++ b/ui/app/src/components/shared/ResourceContextMenu.tsx @@ -15,6 +15,7 @@ import { ConfirmDisableEnableResource } from './ConfirmDisableEnableResource'; import { CreateUpdateResourceContext } from '../../contexts/CreateUpdateResourceContext'; import { Workspace } from '../../models/workspace'; import { WorkspaceService } from '../../models/workspaceService'; +import { successStates } from '../../models/operation'; interface ResourceContextMenuProps { resource: Resource, @@ -91,7 +92,7 @@ export const ResourceContextMenu: React.FunctionComponent { doAction(a.name) } } ); }); - menuItems.push({ key: 'custom-actions', text: 'Actions', iconProps: { iconName: 'Asterisk' }, disabled:props.componentAction === ComponentAction.Lock, subMenuProps: { items: customActions } }); + menuItems.push({ key: 'custom-actions', text: 'Actions', iconProps: { iconName: 'Asterisk' }, disabled:props.componentAction === ComponentAction.Lock || successStates.indexOf(props.resource.deploymentStatus) === -1 || !props.resource.isEnabled, subMenuProps: { items: customActions } }); } switch (props.resource.resourceType) { diff --git a/ui/app/src/components/shared/ResourceHeader.tsx b/ui/app/src/components/shared/ResourceHeader.tsx index 6d3bf0f4c8..8076e93740 100644 --- a/ui/app/src/components/shared/ResourceHeader.tsx +++ b/ui/app/src/components/shared/ResourceHeader.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { ProgressIndicator, Stack } from '@fluentui/react'; import { ResourceContextMenu } from '../shared/ResourceContextMenu'; -import { ComponentAction, Resource } from '../../models/resource'; +import { ComponentAction, Resource, ResourceUpdate } from '../../models/resource'; +import { StatusBadge } from './StatusBadge'; interface ResourceHeaderProps { resource: Resource, - componentAction: ComponentAction + latestUpdate: ResourceUpdate } export const ResourceHeader: React.FunctionComponent = (props: ResourceHeaderProps) => { @@ -15,14 +16,26 @@ export const ResourceHeader: React.FunctionComponent = (pro {props.resource && props.resource.id &&
- -

{props.resource.resourceType.replace('-', ' ')}: {props.resource.properties?.display_name}

+ + + +

+ {props.resource.resourceType.replace('-', ' ')}: {props.resource.properties?.display_name} +

+
+ { + (props.latestUpdate.operation || props.resource.deploymentStatus) && + + + + } +
- + { - props.componentAction === ComponentAction.Lock && + props.latestUpdate.componentAction === ComponentAction.Lock && diff --git a/ui/app/src/components/shared/ResourceHistory.tsx b/ui/app/src/components/shared/ResourceHistory.tsx index 3667a2fa92..83004b12f6 100644 --- a/ui/app/src/components/shared/ResourceHistory.tsx +++ b/ui/app/src/components/shared/ResourceHistory.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { DetailsList, DetailsListLayoutMode, initializeIcons, IColumn, Text } from "@fluentui/react"; +import { DetailsList, DetailsListLayoutMode, IColumn, Text } from "@fluentui/react"; import { Icon } from '@fluentui/react/lib/Icon'; import { CheckboxVisibility } from "@fluentui/react/lib/DetailsList"; import { HistoryItem } from '../../models/resource'; @@ -12,8 +12,6 @@ interface IResourceHistoryProps { export const ResourceHistory: React.FunctionComponent = (props: IResourceHistoryProps) => { - initializeIcons() - const DisabledIcon = () => ; const EnabledIcon = () => ; diff --git a/ui/app/src/components/shared/SharedServiceItem.tsx b/ui/app/src/components/shared/SharedServiceItem.tsx index ee25ee6db7..a859cf4ec6 100644 --- a/ui/app/src/components/shared/SharedServiceItem.tsx +++ b/ui/app/src/components/shared/SharedServiceItem.tsx @@ -19,7 +19,7 @@ export const SharedServiceItem: React.FunctionComponent = () => { const [loadingState, setLoadingState] = useState(LoadingState.Loading); const navigate = useNavigate(); - const componentAction = useComponentManager( + const latestUpdate = useComponentManager( sharedService, (r: Resource) => setSharedService(r as SharedService), (r: Resource) => navigate(`/${ApiEndpoint.SharedServices}`) @@ -39,7 +39,7 @@ export const SharedServiceItem: React.FunctionComponent = () => { case LoadingState.Ok: return ( <> - + = (props: StatusBadgeProps) => { + + let baseClass = "tre-badge"; + if (inProgressStates.indexOf(props.status) !== -1) baseClass += " tre-badge-in-progress"; + if (successStates.indexOf(props.status) !== -1) baseClass += " tre-badge-success"; + if (failedStates.indexOf(props.status) !== -1) baseClass += " tre-badge-failed"; + + return ( + <> + {props.status && {props.status.replace("_", " ")}} + + ); +}; diff --git a/ui/app/src/components/workspaces/UserResourceItem.tsx b/ui/app/src/components/workspaces/UserResourceItem.tsx index e230b3be61..13a8a819bf 100644 --- a/ui/app/src/components/workspaces/UserResourceItem.tsx +++ b/ui/app/src/components/workspaces/UserResourceItem.tsx @@ -15,6 +15,8 @@ import { ResourceOperationsList } from '../shared/ResourceOperationsList'; interface UserResourceItemProps { userResource?: UserResource + updateUserResource: (u: UserResource) => void, + removeUserResource: (u: UserResource) => void } export const UserResourceItem: React.FunctionComponent = (props: UserResourceItemProps) => { @@ -24,10 +26,10 @@ export const UserResourceItem: React.FunctionComponent = const workspaceCtx = useContext(WorkspaceContext); const navigate = useNavigate(); - const componentAction = useComponentManager( + const latestUpdate = useComponentManager( userResource, - (r: Resource) => setUserResource(r as UserResource), - (r: Resource) => navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}`) + (r: Resource) => { props.updateUserResource(r as UserResource); setUserResource(r as UserResource) }, + (r: Resource) => { props.removeUserResource(r as UserResource); navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}`); } ); useEffect(() => { @@ -46,7 +48,7 @@ export const UserResourceItem: React.FunctionComponent = return ( userResource && userResource.id ? <> - + { - const workspaceCtx = useRef(useContext(WorkspaceContext)); + const workspaceCtx = useContext(WorkspaceContext); const navigate = useNavigate(); - const componentAction = useComponentManager( - workspaceCtx.current.workspace, - (r: Resource) => workspaceCtx.current.setWorkspace(r as Workspace), + const latestUpdate = useComponentManager( + workspaceCtx.workspace, + (r: Resource) => workspaceCtx.setWorkspace(r as Workspace), (r: Resource) => navigate(`/`) ); return ( <> - - + + { 'data-title': 'Overview', }} > - - + + - + - + diff --git a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx index 2b3ec37671..c2c07e655f 100644 --- a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx +++ b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx @@ -7,6 +7,7 @@ import { ResourceType } from '../../models/resourceType'; import { WorkspaceContext } from '../../contexts/WorkspaceContext'; import { Resource } from '../../models/resource'; import { CreateUpdateResourceContext } from '../../contexts/CreateUpdateResourceContext'; +import { successStates } from '../../models/operation'; // TODO: // - we lose the selected styling when navigating into a user resource. This may not matter as the user resource page might die away. @@ -43,7 +44,8 @@ export const WorkspaceLeftNav: React.FunctionComponent = serviceLinkArray.push({ name: "Create new", icon: "Add", - key: "create" + key: "create", + disabled: successStates.indexOf(workspaceCtx.workspace.deploymentStatus) === -1 || !workspaceCtx.workspace.isEnabled }); const seviceNavLinks: INavLinkGroup[] = [ @@ -69,7 +71,7 @@ export const WorkspaceLeftNav: React.FunctionComponent = setServiceLinks(seviceNavLinks); }; getWorkspaceServices(); - }, [props.workspaceServices, workspaceCtx.workspace.id]); + }, [props.workspaceServices, workspaceCtx.workspace]); return ( <> diff --git a/ui/app/src/components/workspaces/WorkspaceProvider.tsx b/ui/app/src/components/workspaces/WorkspaceProvider.tsx index f913aa7e0d..cb514da142 100644 --- a/ui/app/src/components/workspaces/WorkspaceProvider.tsx +++ b/ui/app/src/components/workspaces/WorkspaceProvider.tsx @@ -2,10 +2,8 @@ import { MessageBar, MessageBarType, Spinner, SpinnerSize, Stack } from '@fluent import React, { useContext, useEffect, useRef, useState } from 'react'; import { Route, Routes, useParams } from 'react-router-dom'; import { ApiEndpoint } from '../../models/apiEndpoints'; -import { UserResource } from '../../models/userResource'; import { WorkspaceService } from '../../models/workspaceService'; import { HttpMethod, ResultType, useAuthApiCall } from '../../hooks/useAuthApiCall'; -import { UserResourceItem } from './UserResourceItem'; import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceItem } from './WorkspaceItem'; import { WorkspaceLeftNav } from './WorkspaceLeftNav'; @@ -18,7 +16,6 @@ import { Workspace } from '../../models/workspace'; export const WorkspaceProvider: React.FunctionComponent = () => { const apiCall = useAuthApiCall(); const [selectedWorkspaceService, setSelectedWorkspaceService] = useState({} as WorkspaceService); - const [selectedUserResource, setSelectedUserResource] = useState({} as UserResource); const [workspaceServices, setWorkspaceServices] = useState([] as Array) const workspaceCtx = useRef(useContext(WorkspaceContext)); const [loadingState, setLoadingState] = useState('loading'); @@ -76,6 +73,7 @@ export const WorkspaceProvider: React.FunctionComponent = () => { const removeWorkspaceService = (w: WorkspaceService) => { let i = workspaceServices.findIndex((f: WorkspaceService) => f.id === w.id); let ws = [...workspaceServices]; + console.log("removing WS...", ws[i]); ws.splice(i, 1); setWorkspaceServices(ws); } @@ -104,8 +102,13 @@ export const WorkspaceProvider: React.FunctionComponent = () => { removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} /> } /> - setSelectedUserResource(userResource)} />} /> - } /> + updateWorkspaceService(ws) } + removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws) } /> + } /> +
diff --git a/ui/app/src/components/workspaces/WorkspaceServiceItem.tsx b/ui/app/src/components/workspaces/WorkspaceServiceItem.tsx index b35d9782ed..e69a90f0bb 100644 --- a/ui/app/src/components/workspaces/WorkspaceServiceItem.tsx +++ b/ui/app/src/components/workspaces/WorkspaceServiceItem.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { Route, Routes, useNavigate, useParams } from 'react-router-dom'; import { ApiEndpoint } from '../../models/apiEndpoints'; import { useAuthApiCall, HttpMethod } from '../../hooks/useAuthApiCall'; import { UserResource } from '../../models/userResource'; @@ -17,10 +17,13 @@ import { ResourceHeader } from '../shared/ResourceHeader'; import { useComponentManager } from '../../hooks/useComponentManager'; import { ResourceOperationsList } from '../shared/ResourceOperationsList'; import { CreateUpdateResourceContext } from '../../contexts/CreateUpdateResourceContext'; +import { successStates } from '../../models/operation'; +import { UserResourceItem } from './UserResourceItem'; interface WorkspaceServiceItemProps { workspaceService?: WorkspaceService, - setUserResource: (userResource: UserResource) => void + updateWorkspaceService: (ws: WorkspaceService) => void, + removeWorkspaceService: (ws: WorkspaceService) => void } export const WorkspaceServiceItem: React.FunctionComponent = (props: WorkspaceServiceItemProps) => { @@ -28,21 +31,22 @@ export const WorkspaceServiceItem: React.FunctionComponent) const [workspaceService, setWorkspaceService] = useState({} as WorkspaceService) const [loadingState, setLoadingState] = useState(LoadingState.Loading); + const [selectedUserResource, setSelectedUserResource] = useState({} as UserResource); const workspaceCtx = useContext(WorkspaceContext); const createFormCtx = useContext(CreateUpdateResourceContext); const navigate = useNavigate(); const apiCall = useAuthApiCall(); - const componentAction = useComponentManager( + const latestUpdate = useComponentManager( workspaceService, - (r: Resource) => setWorkspaceService(r as WorkspaceService), - (r: Resource) => navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}`) + (r: Resource) => { props.updateWorkspaceService(r as WorkspaceService); setWorkspaceService(r as WorkspaceService) }, + (r: Resource) => { props.removeWorkspaceService(r as WorkspaceService); navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}`) } ); useEffect(() => { const getData = async () => { try { // did we get passed the workspace service, or shall we get it from the api? - if (props.workspaceService && props.workspaceService.id) { + if (props.workspaceService && props.workspaceService.id && props.workspaceService.id === workspaceServiceId) { setWorkspaceService(props.workspaceService); } else { let ws = await apiCall(`${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}`, HttpMethod.Get, workspaceCtx.workspaceClientId); @@ -84,54 +88,68 @@ export const WorkspaceServiceItem: React.FunctionComponent - - - - + + + + + + - - - - - - - - - + + + + + + + + + + + + + +

User Resources

+ { + createFormCtx.openCreateForm({ + resourceType: ResourceType.UserResource, + resourceParent: workspaceService, + onAdd: (r: Resource) => addUserResource(r as UserResource), + workspaceClientId: workspaceCtx.workspaceClientId + }) + }} /> +
+
+ + { + userResources && + setSelectedUserResource(r as UserResource)} + updateResource={(r: Resource) => updateUserResource(r as UserResource)} + removeResource={(r: Resource) => removeUserResource(r as UserResource)} + emptyText="This workspace service contains no user resources." /> + } + +
+ + } /> + updateUserResource(u)} + removeUserResource={(u: UserResource) => removeUserResource(u)} + /> + } /> + - - - -

User Resources

- { - createFormCtx.openCreateForm({ - resourceType: ResourceType.UserResource, - resourceParent: props.workspaceService, - onAdd: (r: Resource) => addUserResource(r as UserResource), - workspaceClientId: workspaceCtx.workspaceClientId - }) - }} /> -
-
- - { - userResources && - props.setUserResource(r as UserResource)} - updateResource={(r: Resource) => updateUserResource(r as UserResource)} - removeResource={(r: Resource) => removeUserResource(r as UserResource)} - emptyText="This workspace service contains no user resources." /> - } - -
); case LoadingState.Error: diff --git a/ui/app/src/components/workspaces/WorkspaceServices.tsx b/ui/app/src/components/workspaces/WorkspaceServices.tsx index 5f4449a43c..46429978f2 100644 --- a/ui/app/src/components/workspaces/WorkspaceServices.tsx +++ b/ui/app/src/components/workspaces/WorkspaceServices.tsx @@ -6,6 +6,7 @@ import { PrimaryButton, Stack } from '@fluentui/react'; import { ResourceType } from '../../models/resourceType'; import { WorkspaceContext } from '../../contexts/WorkspaceContext'; import { CreateUpdateResourceContext } from '../../contexts/CreateUpdateResourceContext'; +import { successStates } from '../../models/operation'; interface WorkspaceServicesProps { workspaceServices: Array, @@ -25,7 +26,7 @@ export const WorkspaceServices: React.FunctionComponent

Workspace Services

- { + { createFormCtx.openCreateForm({ resourceType: ResourceType.WorkspaceService, resourceParent: workspaceCtx.workspace, diff --git a/ui/app/src/hooks/useComponentManager.ts b/ui/app/src/hooks/useComponentManager.ts index d146c59863..d3e23a92fe 100644 --- a/ui/app/src/hooks/useComponentManager.ts +++ b/ui/app/src/hooks/useComponentManager.ts @@ -7,32 +7,32 @@ import { HttpMethod, useAuthApiCall } from "./useAuthApiCall"; export const useComponentManager = (resource: Resource, onUpdate: (r: Resource) => void, onRemove: (r: Resource) => void) => { const opsReadContext = useContext(NotificationsContext); const opsWriteContext = useRef(useContext(NotificationsContext)); - const [componentAction, setComponentAction] = useState(ComponentAction.None); + const [latestUpdate, setLatestUpdate] = useState({} as ResourceUpdate); const workspaceCtx = useContext(WorkspaceContext); const apiCall = useAuthApiCall(); // set the latest component action useEffect(() => { let updates = opsReadContext.resourceUpdates.filter((r: ResourceUpdate) => { return r.resourceId === resource.id }); - setComponentAction((updates && updates.length > 0) ? - updates[updates.length - 1].componentAction : - ComponentAction.None); + setLatestUpdate((updates && updates.length > 0) ? + updates[updates.length - 1] : + { componentAction: ComponentAction.None } as ResourceUpdate); }, [opsReadContext.resourceUpdates, resource.id]) // act on component action changes useEffect(() => { const checkForReload = async () => { - if (componentAction === ComponentAction.Reload) { + if (latestUpdate.componentAction === ComponentAction.Reload) { let result = await apiCall(resource.resourcePath, HttpMethod.Get, workspaceCtx.workspaceClientId); opsWriteContext.current.clearUpdatesForResource(resource.id); onUpdate(getResourceFromResult(result)); - } else if (componentAction === ComponentAction.Remove) { + } else if (latestUpdate.componentAction === ComponentAction.Remove) { opsWriteContext.current.clearUpdatesForResource(resource.id); onRemove(resource); } } checkForReload(); - }, [apiCall, componentAction, workspaceCtx.workspaceClientId, resource, onUpdate, onRemove, resource.resourcePath]); + }, [apiCall, latestUpdate, workspaceCtx.workspaceClientId, resource, onUpdate, onRemove, resource.resourcePath]); - return componentAction; + return latestUpdate; } diff --git a/ui/app/src/models/operation.ts b/ui/app/src/models/operation.ts index df8a160427..7cbb25a72d 100644 --- a/ui/app/src/models/operation.ts +++ b/ui/app/src/models/operation.ts @@ -51,4 +51,11 @@ export const failedStates = [ "deleting_failed", "action_failed", "pipeline_failed", -] \ No newline at end of file +] + +export const successStates = [ + "deployed", + "deleted", + "action_succeeded", + "pipeline_succeeded" +] diff --git a/ui/app/src/models/resource.ts b/ui/app/src/models/resource.ts index 718f9f7c84..2b1c276e53 100644 --- a/ui/app/src/models/resource.ts +++ b/ui/app/src/models/resource.ts @@ -11,6 +11,7 @@ export interface Resource { resourceType: ResourceType templateName: string, templateVersion: string, + deploymentStatus: string, updatedWhen: number, user: User, history: Array,