src/app/deploy/sections/automatic/automatic.component.ts
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 jsonFile: any = null
  public autodeploying: boolean = false
  public autodeployDone: boolean = false
  public recreateDatabaseModal: boolean = false
  public isSubmittingJson: boolean = false
  public isJsonSubmitted: boolean = false
  /**
   * Default was `false` when deploy was done with frontend and backend separately.
   * Now we are using only streaming app, so we always want to recreate database (makedata)
   */
  public recreateDatabase: boolean = true
  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[] = []

  /** autoDeployStatus
   * This object presents the status for two steps that we have for deploy.
   * `deployServicePack` - Creating services based on `viya.json`
   * `runMakeData` - Running `makedata` service
   * If any of them is `null` or `false` that means step failed
   * and will be shown to user on deploy done modal.
   */
  public autoDeployStatus: {
    deployServicePack: any
    runMakeData: any
  } = {
    deployServicePack: null,
    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
    )
  }

  /**
   * Executes sas.json file to deploy the backend
   * Method will first try to run the `auto deploy`
   * If that fails the rest of the code is ignored.
   * If request is successfull, method will continue to try
   * to create database if checkbox is toggled on
   */
  public async executeJson() {
    this.isSubmittingJson = true

    try {
      let uploadJsonFile = await this.sasJs.deployServicePack(
        this.jsonFile,
        this.dcAdapterSettings?.appLoc,
        undefined,
        undefined,
        true
      )

      this.autoDeployStatus.deployServicePack = true
      this.isJsonSubmitted = true
    } catch (ex: any) {
      let textEx = ''

      if (typeof ex.message !== 'string') {
        textEx = JSON.stringify(ex).replace(/\\/gm, '')
      } else {
        textEx = ex.message
      }

      this.autoDeployStatus.deployServicePack = false
      this.eventService.showInfoModal(
        'Deploy error',
        `Exception: \n ${textEx !== '' ? textEx : ex}`
      )
      this.autodeploying = false
      this.autodeployDone = false

      return
    }

    this.isSubmittingJson = false
  }

  public async runAutoDeploy(executeJson: boolean = false) {
    if (!this.deployInNewWindow) this.autodeploying = true

    if (executeJson) {
      this.executeJson()
    }

    if (this.recreateDatabase) {
      this.createDatabase()
    } else {
      if (!this.deployInNewWindow) this.autodeployDone = true
    }
  }

  /**
   * 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=${params.contextName}`
    let admin = `&admin=${params.admin}`
    let dcPath = `&dcpath=${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 async onJsonFileChange(event: any) {
    let file = event.target.files[0]

    this.jsonFile = await this.deployService.readFile(file)
  }

  public recreateDatabaseClicked(event: Event) {
    ;(<HTMLInputElement>event.target).checked === true
      ? (this.recreateDatabaseModal = true)
      : ''
  }

  public clearUploadInput(event: Event) {
    this.deployService.clearUploadInput(event)
  }

  public openSasRequestsModal() {
    this.eventService.openRequestsModal()
  }

  public navigateToHome() {
    this.onNavigateToHome.emit()
  }
}

results matching ""

    No results matching ""