Addresses both findings from the review of #408. runAutoDeploy was declared async after the service-pack step went, but calls createDatabase() and returns without awaiting anything. createDatabase() starts the request and reports through the component's own status flags rather than a returned promise, so there is nothing to await - drop the async rather than pretend to a contract the callee does not offer. runMakedataInNewWindow interpolated the context name, admin group and DC path straight into the query string. An ampersand in any of them started a new parameter and a space arrived truncated; a DC path containing a slash was only working because nothing decoded the query. Those three values are now percent-encoded at the build site, so the server reads back what the user chose. Tests: the new-window case now asserts the encoded forms, and a second case pins the values that need encoding (context with an ampersand and spaces, admin group with a space and a plus, DC path with a slash and a space). The deploy case no longer awaits a synchronous handler. Executed 24 of 24 SUCCESS (automatic + sasjs-configurator + deploy components).
660 lines
20 KiB
TypeScript
660 lines
20 KiB
TypeScript
import {
|
|
Component,
|
|
EventEmitter,
|
|
Input,
|
|
OnInit,
|
|
Output,
|
|
ViewEncapsulation
|
|
} from '@angular/core'
|
|
import SASjs, { SASjsConfig } from '@sasjs/adapter'
|
|
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
|
|
import { HelperService } from 'src/app/services'
|
|
import { DeployService } from 'src/app/services/deploy.service'
|
|
import { EventService } from 'src/app/services/event.service'
|
|
import { LoggerService } from 'src/app/services/logger.service'
|
|
import { SasViyaService } from 'src/app/services/sas-viya.service'
|
|
import { SasService } from 'src/app/services/sas.service'
|
|
import { ViyaApiCurrentUser } from 'src/app/viya-api-explorer/models/viya-api-current-user.model'
|
|
import {
|
|
Item,
|
|
ViyaApiIdentities
|
|
} from 'src/app/viya-api-explorer/models/viya-api-identities.model'
|
|
import { ComputeContextDetails } from 'src/app/viya-api-explorer/models/viya-compute-context-details.model'
|
|
import {
|
|
ViyaComputeContexts,
|
|
Item as ComputeContextItem
|
|
} from 'src/app/viya-api-explorer/models/viya-compute-contexts.model'
|
|
|
|
export interface ComputeContextInfo {
|
|
id: string
|
|
name: string
|
|
runAs: string | null
|
|
reusable: boolean
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-automatic-deploy',
|
|
templateUrl: './automatic.component.html',
|
|
styleUrls: ['./automatic.component.scss'],
|
|
encapsulation: ViewEncapsulation.None,
|
|
standalone: false
|
|
})
|
|
export class AutomaticComponent implements OnInit {
|
|
@Input() sasJs!: SASjs
|
|
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
|
|
@Input() dcAdapterSettings: DcAdapterSettings | undefined
|
|
@Input() appLoc: string = ''
|
|
@Input() dcPath: string = ''
|
|
@Input() selectedAdminGroup: string = ''
|
|
|
|
@Output() onNavigateToHome: EventEmitter<any> = new EventEmitter<any>()
|
|
|
|
public selectedComputeContext: string = ''
|
|
public makeDataResponse: string = ''
|
|
public autodeploying: boolean = false
|
|
public autodeployDone: boolean = false
|
|
public createDatabaseLoading: boolean = false
|
|
public adminGroupsLoading: boolean = false
|
|
public currentUserInfoLoading: boolean = false
|
|
public computeContextsLoading: boolean = false
|
|
public adminGroups: { id: string; name: string }[] = []
|
|
/** Compute contexts enriched with their batch identity and reusability */
|
|
public computeContextInfos: ComputeContextInfo[] = []
|
|
/** Distinct batch identities (runServerAs) across reusable contexts */
|
|
public batchIds: string[] = []
|
|
public selectedBatchId: string = ''
|
|
/**
|
|
* When enabled, any compute context can be chosen (the batch id dropdown
|
|
* is hidden and no reusability / batch id filtering is applied)
|
|
*/
|
|
public showAllContexts: boolean = false
|
|
/** All groups on the Viya server */
|
|
public allAdminGroups: { id: string; name: string }[] = []
|
|
/** Groups the current user is a member of */
|
|
public memberAdminGroups: { id: string; name: string }[] = []
|
|
public showAllGroups: boolean = false
|
|
public runningAsUser: string | undefined
|
|
public currentUserInfo: ViyaApiCurrentUser | null = null
|
|
public computeContexts: ComputeContextItem[] = []
|
|
|
|
/**
|
|
* Status of the one step this screen runs: `makedata`, which creates the
|
|
* control library and its configuration data and then deletes itself.
|
|
* `null` means it has not run yet, `false` that it failed.
|
|
*/
|
|
public autoDeployStatus: {
|
|
runMakeData: any
|
|
} = {
|
|
runMakeData: null
|
|
}
|
|
|
|
public sasjsConfig = this.sasService.getSasjsConfig()
|
|
|
|
/**
|
|
* makedata service will be run in a new window
|
|
* This is needed to ensure that the user can see the logs
|
|
* and the progress of the service execution.
|
|
* If this is set to `false`, the service will be run in the same window
|
|
* using the adapter request method.
|
|
*/
|
|
public deployInNewWindow: boolean = true
|
|
|
|
constructor(
|
|
private eventService: EventService,
|
|
private deployService: DeployService,
|
|
private sasService: SasService,
|
|
private sasViyaService: SasViyaService,
|
|
private loggerService: LoggerService,
|
|
private helperService: HelperService
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.loadData()
|
|
}
|
|
|
|
public async loadData() {
|
|
await this.getCurrentUser()
|
|
await this.getAdminGroups()
|
|
await this.getComputeContexts()
|
|
|
|
setTimeout(() => {
|
|
if (this.selectedComputeContext) {
|
|
this.onComputeContextChange(this.selectedComputeContext)
|
|
}
|
|
}, 500)
|
|
}
|
|
|
|
public async getComputeContexts() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.computeContextsLoading = true
|
|
|
|
this.sasViyaService.getComputeContexts().subscribe(
|
|
async (res: ViyaComputeContexts) => {
|
|
this.computeContexts = res.items
|
|
|
|
await this.getComputeContextDetails()
|
|
|
|
this.computeContextsLoading = false
|
|
|
|
resolve()
|
|
},
|
|
(err) => {
|
|
reject(err)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The `/compute/contexts` list view is minimal, so each context is fetched
|
|
* in full (in parallel) to pick up its batch identity (runServerAs) and
|
|
* whether it reuses server processes. Only reusable contexts with a batch
|
|
* identity feed the batch id dropdown.
|
|
*/
|
|
public async getComputeContextDetails() {
|
|
const infos = await Promise.all(
|
|
this.computeContexts.map(
|
|
(context: ComputeContextItem) =>
|
|
new Promise<ComputeContextInfo>((resolveInfo) => {
|
|
this.sasViyaService.getComputeContextById(context.id).subscribe(
|
|
(details: ComputeContextDetails) => {
|
|
resolveInfo({
|
|
id: context.id,
|
|
name: context.name,
|
|
runAs: this.extractRunAs(details),
|
|
reusable: details.attributes?.reuseServerProcesses === 'true'
|
|
})
|
|
},
|
|
(err: any) => {
|
|
this.loggerService.error(
|
|
`Error while getting compute context ${context.name}`,
|
|
err
|
|
)
|
|
|
|
resolveInfo({
|
|
id: context.id,
|
|
name: context.name,
|
|
runAs: null,
|
|
reusable: false
|
|
})
|
|
}
|
|
)
|
|
})
|
|
)
|
|
)
|
|
|
|
this.computeContextInfos = infos
|
|
|
|
this.batchIds = [
|
|
...new Set(
|
|
infos
|
|
.filter((info) => info.reusable && info.runAs)
|
|
.map((info) => info.runAs as string)
|
|
)
|
|
]
|
|
|
|
if (this.batchIds.length) {
|
|
this.selectedBatchId = this.batchIds[0]
|
|
}
|
|
|
|
const filteredContexts = this.getFilteredComputeContexts()
|
|
|
|
const defaultContext =
|
|
filteredContexts.find(
|
|
(info: ComputeContextInfo) =>
|
|
info.name === 'SAS Job Execution compute context'
|
|
) || filteredContexts[0]
|
|
|
|
if (defaultContext) {
|
|
this.selectedComputeContext = defaultContext.id
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pulls the runAs (batch) identity out of a compute context. The batch user
|
|
* is exposed as `attributes.runServerAs` in the Viya compute context API
|
|
* response. Not all contexts expose one.
|
|
*/
|
|
public extractRunAs(details: ComputeContextDetails): string | null {
|
|
return details.attributes?.runServerAs || null
|
|
}
|
|
|
|
/**
|
|
* The contexts offered in the dropdown. By default only reusable contexts
|
|
* (`reuseServerProcesses`) matching the selected batch id are shown. When
|
|
* the `showAllContexts` toggle is on, all contexts are shown.
|
|
*/
|
|
public getFilteredComputeContexts(): ComputeContextInfo[] {
|
|
if (this.showAllContexts) {
|
|
return this.computeContextInfos
|
|
}
|
|
|
|
return this.computeContextInfos.filter(
|
|
(info: ComputeContextInfo) =>
|
|
info.reusable && info.runAs === this.selectedBatchId
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Changing the batch id re-filters the compute context dropdown and makes
|
|
* sure the currently selected context exists in the displayed list.
|
|
*/
|
|
public onBatchIdChange() {
|
|
this.ensureSelectedComputeContext()
|
|
}
|
|
|
|
/**
|
|
* Toggles the compute context dropdown between contexts matching the
|
|
* selected batch id and all contexts on the Viya server.
|
|
*/
|
|
public onShowAllContextsChange() {
|
|
this.ensureSelectedComputeContext()
|
|
}
|
|
|
|
private ensureSelectedComputeContext() {
|
|
const filteredContexts = this.getFilteredComputeContexts()
|
|
|
|
const selectedContextExists = filteredContexts.some(
|
|
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
|
|
)
|
|
|
|
if (!selectedContextExists) {
|
|
this.selectedComputeContext = filteredContexts.length
|
|
? filteredContexts[0].id
|
|
: ''
|
|
}
|
|
|
|
this.updateRunningAsUser()
|
|
}
|
|
|
|
private updateRunningAsUser() {
|
|
const selectedContext = this.computeContextInfos.find(
|
|
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
|
|
)
|
|
|
|
this.runningAsUser =
|
|
selectedContext?.runAs || this.currentUserInfo?.id || 'unknown'
|
|
}
|
|
|
|
public async getCurrentUser() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.currentUserInfoLoading = true
|
|
|
|
this.sasViyaService.getCurrentUser().subscribe(
|
|
(res: ViyaApiCurrentUser) => {
|
|
this.currentUserInfoLoading = false
|
|
|
|
this.currentUserInfo = res
|
|
|
|
this.dcPath = `/export/viya/homes/${res.id}`
|
|
|
|
resolve()
|
|
},
|
|
(err) => {
|
|
console.error('Error while getting current user', err)
|
|
reject(err)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
public async getAdminGroups() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.adminGroupsLoading = true
|
|
;(this.sasViyaService
|
|
.getAdminGroups()
|
|
.subscribe(async (res: ViyaApiIdentities) => {
|
|
// Map admin groups with only needed fields
|
|
this.allAdminGroups = res.items.map((item: Item) => {
|
|
return {
|
|
id: item.id,
|
|
name: item.name
|
|
}
|
|
})
|
|
|
|
await this.getMemberAdminGroups()
|
|
|
|
this.applyAdminGroupFilter()
|
|
|
|
this.adminGroupsLoading = false
|
|
|
|
resolve()
|
|
}),
|
|
(err: any) => {
|
|
this.adminGroupsLoading = false
|
|
this.loggerService.error('Error while getting admin groups', err)
|
|
this.eventService.showAbortModal('admin groups', err)
|
|
|
|
reject(err)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetches the groups the current user is a member of, from the Viya
|
|
* identities API. If the memberships cannot be fetched, the member list
|
|
* falls back to all groups.
|
|
*/
|
|
public async getMemberAdminGroups() {
|
|
return new Promise<void>((resolve) => {
|
|
this.sasViyaService.getCurrentUserGroupMemberships().subscribe(
|
|
(res: ViyaApiIdentities) => {
|
|
const memberIds = res.items.map((item: Item) => item.id)
|
|
|
|
this.memberAdminGroups = this.allAdminGroups.filter(
|
|
(group: { id: string; name: string }) =>
|
|
memberIds.includes(group.id)
|
|
)
|
|
|
|
resolve()
|
|
},
|
|
(err: any) => {
|
|
this.loggerService.error(
|
|
'Error while getting user group memberships',
|
|
err
|
|
)
|
|
this.memberAdminGroups = this.allAdminGroups
|
|
|
|
resolve()
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Toggles the SAS Admin group dropdown between the groups the current user
|
|
* is a member of and all groups on the Viya server.
|
|
*/
|
|
public onShowAllGroupsChange() {
|
|
this.applyAdminGroupFilter()
|
|
}
|
|
|
|
/**
|
|
* Applies the member/all groups filter to the dropdown and makes sure the
|
|
* currently selected group exists in the displayed list.
|
|
*/
|
|
private applyAdminGroupFilter() {
|
|
this.adminGroups =
|
|
this.showAllGroups || !this.memberAdminGroups.length
|
|
? this.allAdminGroups
|
|
: this.memberAdminGroups
|
|
|
|
const selectedGroupExists = this.adminGroups.some(
|
|
(group: { id: string; name: string }) =>
|
|
group.id === this.selectedAdminGroup
|
|
)
|
|
|
|
if (!selectedGroupExists && this.adminGroups.length) {
|
|
this.selectedAdminGroup = this.adminGroups[0].id
|
|
}
|
|
}
|
|
|
|
public async onComputeContextChange(computeContextId: string) {
|
|
// The explicit (ngModelChange) handler runs before the [(ngModel)]
|
|
// assignment updates the field, so synchronise the selected context id
|
|
// here before deriving the running user. Otherwise the displayed user
|
|
// lags behind the dropdown by one change.
|
|
this.selectedComputeContext = computeContextId
|
|
this.updateRunningAsUser()
|
|
}
|
|
|
|
public getComputeContextName(id: string): string | undefined {
|
|
return (
|
|
this.computeContexts.find(
|
|
(context: ComputeContextItem) => context.id === id
|
|
)?.name || undefined
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Runs the deployment. The control library and its configuration data are
|
|
* created by `makedata`, which self-deletes once it has run; the frontend and
|
|
* services arrived with the sasjs deploy, so there is nothing else to deploy
|
|
* from here.
|
|
*/
|
|
public runAutoDeploy() {
|
|
if (!this.deployInNewWindow) this.autodeploying = true
|
|
|
|
this.createDatabase()
|
|
}
|
|
|
|
/**
|
|
* Runs the `makedata` request sending the ADMIN and DCPATH values
|
|
*/
|
|
public createDatabase() {
|
|
let data = {
|
|
fromjs: [
|
|
{
|
|
ADMIN: this.selectedAdminGroup,
|
|
DCPATH: this.dcPath
|
|
}
|
|
]
|
|
}
|
|
|
|
// Get and run service using the selected context name
|
|
let selectedComputeContextName = this.sasJsConfig.contextName
|
|
|
|
if (this.selectedComputeContext.length && this.computeContexts.length) {
|
|
const computeContextName = this.getComputeContextName(
|
|
this.selectedComputeContext
|
|
)
|
|
|
|
if (computeContextName) {
|
|
selectedComputeContextName = computeContextName
|
|
}
|
|
}
|
|
|
|
/**
|
|
* We are overriding default `sasjsConfig` object fields with this object fields.
|
|
* Here we want to run this request using original WEB method.
|
|
* contextName: null is the MUST field for it.
|
|
*/
|
|
let overrideConfig = {
|
|
useComputeApi: null,
|
|
contextName: selectedComputeContextName,
|
|
debug: true
|
|
}
|
|
|
|
if (this.deployInNewWindow) {
|
|
this.runMakedataInNewWindow({
|
|
contextName: selectedComputeContextName,
|
|
admin: this.selectedAdminGroup,
|
|
dcPath: this.dcPath
|
|
})
|
|
} else {
|
|
this.sasJs
|
|
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
|
this.sasService.shouldLogin.next(true)
|
|
})
|
|
.then((res: any) => {
|
|
this.autodeployDone = true
|
|
|
|
try {
|
|
this.makeDataResponse = JSON.stringify(res)
|
|
} catch {
|
|
this.makeDataResponse = res
|
|
}
|
|
|
|
if (res.result && res.result.length > 0) {
|
|
this.autoDeployStatus.runMakeData = true
|
|
} else {
|
|
this.autoDeployStatus.runMakeData = false
|
|
}
|
|
|
|
if (typeof res.sasjsAbort !== 'undefined') {
|
|
const abortRes = res
|
|
const abortMsg = abortRes.sasjsAbort[0].MSG
|
|
const macMsg = abortRes.sasjsAbort[0].MAC
|
|
|
|
this.eventService.showAbortModal('makedata', abortMsg, {
|
|
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
|
|
SYSERRORTEXT: abortRes.SYSERRORTEXT,
|
|
MAC: macMsg,
|
|
_PROGRAM: abortRes._PROGRAM
|
|
})
|
|
}
|
|
|
|
if (this.helperService.isStreamingViya())
|
|
this.updateIndexHtmlComputeContext()
|
|
})
|
|
.catch((err: any) => {
|
|
this.eventService.showAbortModal('makedata', JSON.stringify(err))
|
|
this.autoDeployStatus.runMakeData = false
|
|
this.autodeployDone = true
|
|
|
|
try {
|
|
this.makeDataResponse = JSON.stringify(err)
|
|
} catch {
|
|
this.makeDataResponse = err
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
public runMakedataInNewWindow(params: {
|
|
contextName: string
|
|
admin: string
|
|
dcPath: string
|
|
}) {
|
|
let serverUrl = this.sasjsConfig.serverUrl
|
|
let appLoc = this.sasjsConfig.appLoc
|
|
const execPath = this.sasService.getExecutionPath()
|
|
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
|
|
let admin = `&admin=${encodeURIComponent(params.admin)}`
|
|
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
|
|
// Debug is ALWAYS enabled for makedata — it runs only once during
|
|
// deployment and the full log is needed to diagnose any issues.
|
|
let debug = '&_debug=131'
|
|
|
|
let programUrl =
|
|
serverUrl +
|
|
execPath +
|
|
'/?_program=' +
|
|
appLoc +
|
|
'/services/admin/makedata' +
|
|
contextname +
|
|
admin +
|
|
dcPath +
|
|
debug
|
|
|
|
window.open(programUrl)
|
|
|
|
// Poll for makedata completion. The makedata service self-deletes
|
|
// when it finishes successfully, so we check if it's still present
|
|
// in the admin folder. When it's gone, reload the app fresh.
|
|
this.pollMakedataCompletion()
|
|
}
|
|
|
|
private makedataPollInterval: any = null
|
|
|
|
private pollMakedataCompletion() {
|
|
if (this.makedataPollInterval) {
|
|
clearInterval(this.makedataPollInterval)
|
|
}
|
|
|
|
let attempts = 0
|
|
const maxAttempts = 120 // 10 minutes at 5s intervals
|
|
|
|
this.makedataPollInterval = setInterval(async () => {
|
|
attempts++
|
|
console.log(
|
|
`pollMakedataCompletion: checking if makedata is gone (attempt ${attempts}/${maxAttempts})`
|
|
)
|
|
|
|
try {
|
|
const makedataGone = await this.sasService.viyaMakedataSuccessfull()
|
|
if (makedataGone) {
|
|
console.log(
|
|
'pollMakedataCompletion: makedata job is gone, reloading app'
|
|
)
|
|
clearInterval(this.makedataPollInterval)
|
|
this.makedataPollInterval = null
|
|
|
|
// Reload the app fresh from the SASJobExecution URL
|
|
window.location.href = window.location.href.split('#')[0]
|
|
}
|
|
} catch (err) {
|
|
console.error('pollMakedataCompletion: error checking', err)
|
|
}
|
|
|
|
if (attempts >= maxAttempts) {
|
|
console.warn(
|
|
'pollMakedataCompletion: timed out after 10 minutes, stopping poll'
|
|
)
|
|
clearInterval(this.makedataPollInterval)
|
|
this.makedataPollInterval = null
|
|
}
|
|
}, 5000)
|
|
}
|
|
|
|
/**
|
|
* Only when on Viya streamed app, this method will update the `contextname` in the `index.html` on the SAS drive
|
|
* This is needed to ensure that the DC will use the same compute context `makedata` service used to run against.
|
|
*/
|
|
public async updateIndexHtmlComputeContext() {
|
|
const filenamePath = location.search.split('/').pop()
|
|
const filename = filenamePath?.includes('.') ? filenamePath : undefined
|
|
|
|
if (!filename) {
|
|
this.eventService.showAbortModal(
|
|
null,
|
|
'We could not figure out the file name of `index.html` based on the url.'
|
|
)
|
|
return
|
|
}
|
|
|
|
const indexHtmlContent = await this.sasService.getFileContent(
|
|
`${this.appLoc}/services`,
|
|
filename
|
|
)
|
|
|
|
if (!indexHtmlContent) {
|
|
this.loggerService.error(
|
|
`Failed to get ${filename} at ${this.appLoc}/services`
|
|
)
|
|
return
|
|
}
|
|
|
|
const computeContextName = this.getComputeContextName(
|
|
this.selectedComputeContext
|
|
)
|
|
|
|
if (!computeContextName) {
|
|
this.loggerService.error(
|
|
`Compute context name not found for ID: ${this.selectedComputeContext} | List: ${JSON.stringify(this.computeContexts)}`
|
|
)
|
|
return
|
|
}
|
|
|
|
/* Use a function replacement so $ in computeContextName is treated as a
|
|
literal character. In a string replacement, $ has special meaning
|
|
($&, $1, $` etc.); a function return value is used verbatim. */
|
|
const updatedContent = indexHtmlContent.replace(
|
|
/contextname="[^"]*"/g,
|
|
() => `contextname="${computeContextName}"`
|
|
)
|
|
|
|
await this.sasService
|
|
.updateFileContent(`${this.appLoc}/services`, filename, updatedContent)
|
|
.catch((err: any) => {
|
|
this.loggerService.error(`Failed to update DataController.html: ${err}`)
|
|
})
|
|
}
|
|
|
|
public downloadFile(
|
|
content: any,
|
|
filename: string,
|
|
extension: string = 'txt'
|
|
) {
|
|
this.deployService.downloadFile(content, filename, extension)
|
|
}
|
|
|
|
public openSasRequestsModal() {
|
|
this.eventService.openRequestsModal()
|
|
}
|
|
|
|
public navigateToHome() {
|
|
this.onNavigateToHome.emit()
|
|
}
|
|
}
|