import '@material/web/icon/icon.js' import '@operato/data-grist' import './scenario-detail.js' import './scenario-importer.js' import '../viewparts/scenario-versions.js' import gql from 'graphql-tag' import { css, html } from 'lit' import { customElement, property, query, state } from 'lit/decorators.js' import { DataGrist } from '@operato/data-grist/ox-grist.js' import { client } from '@operato/graphql' import { i18next, localize } from '@operato/i18n' import { notify, openPopup } from '@operato/layout' import { OxPrompt } from '@operato/popup/ox-prompt.js' import { navigate, PageView } from '@operato/shell' import { CommonButtonStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles' import { isMobileDevice } from '@operato/utils' import { FetchOption } from '@operato/data-grist' import { p13n } from '@operato/p13n' function IS_SCENARIO_RUNNING(state) { return state && state !== 'UNLOADED' } @customElement('scenario-page') export class Scenario extends p13n(localize(i18next)(PageView)) { static get styles() { return [ CommonGristStyles, ScrollbarStyles, css` :host { display: flex; flex-direction: column; overflow: hidden; } ox-grist { overflow-y: auto; flex: 1; } ` ] } @property({ type: Boolean }) active: boolean = false @property({ type: Object }) gristConfig: any @property({ type: Object }) taskTypes: any @query('ox-grist') grist!: DataGrist get context() { return { title: i18next.t('text.scenario list'), search: { handler: search => { this.grist.searchText = search }, value: this.grist?.searchText || '' }, // 필터가 설정되면, 아래 코멘트 해제 // filter: { // handler: () => { // const display = this.headroom.style.display // this.headroom.style.display = display !== 'none' ? 'none' : 'flex' // } // }, help: 'integration/ui/scenario', actions: [ { title: i18next.t('button.start monitor'), action: () => { navigate('integration-monitor') }, ...CommonButtonStyles.preview }, { title: i18next.t('button.copy'), action: this._copyScenario.bind(this), ...CommonButtonStyles.copy }, { title: i18next.t('button.save'), action: this._updateScenario.bind(this), ...CommonButtonStyles.save }, { title: i18next.t('button.delete'), action: this._deleteScenario.bind(this), ...CommonButtonStyles.delete } ], exportable: { name: i18next.t('text.scenario list'), data: this.exportHandler.bind(this) }, importable: { handler: this.importHandler.bind(this) } } } render() { return html` ` } async pageInitialized(lifecycle) { this.fetchTaskTypes() this.gristConfig = { list: { fields: ['name', 'description', 'schedule', 'active'] }, columns: [ { type: 'gutter', gutterName: 'sequence', fixed: true }, { type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true }, { type: 'gutter', gutterName: 'button', icon: record => (!record ? 'calendar_add_on' : !record.id || !record.scheduleId ? '' : 'event_available'), title: i18next.t('button.schedule-task'), handlers: { click: (columns, data, column, record, rowIndex) => { if (!record || !record.id || (!record.scheduleId && !record.schedule)) { return } if (record.scheduleId) { this.stopScenarioSchedule(record) } else { this.startScenarioSchedule(record) } } } }, { type: 'gutter', gutterName: 'button', name: 'state', fixed: true, icon: record => !record ? 'play_arrow' : record.id ? (IS_SCENARIO_RUNNING(record.state) ? 'pause' : 'play_arrow') : '', iconOnly: false, title: record => !record ? i18next.t('button.start') : record.id ? IS_SCENARIO_RUNNING(record.state) ? i18next.t('button.stop') : i18next.t('button.start') : '', width: 72, handlers: { click: (columns, data, column, record, rowIndex) => { if (!record || !record.name) { /* TODO record가 새로 추가된 것이면 리턴하도록 한다. */ return } if (IS_SCENARIO_RUNNING(record.state)) { this.stopScenario(record) } else { this.startScenario(record) } } } }, { type: 'gutter', gutterName: 'button', fixed: true, icon: record => (!record ? 'reorder' : record.id ? 'reorder' : ''), iconOnly: false, title: record => (!record ? i18next.t('button.detail') : record.id ? i18next.t('button.detail') : ''), width: 72, handlers: { click: (columns, data, column, record, rowIndex) => { if (!record.id) return openPopup( html` `, { backdrop: true, help: 'integration/ui/scenario-detail', size: 'large', title: i18next.t('title.scenario-detail') } ) } } }, { type: 'string', name: 'name', fixed: true, label: true, header: i18next.t('field.name'), record: { editable: true, mandatory: true }, filter: 'search', sortable: true, width: 300, validation: function (after, before, record, column) { /* connected 상태에서는 이름을 바꿀 수 없다. */ if (IS_SCENARIO_RUNNING(record.state)) { notify({ level: 'warn', message: 'scenario name cannot be changed during execution.' }) return false } return true } }, { type: 'string', name: 'type', header: i18next.t('field.type'), record: { editable: true }, filter: 'search', sortable: true, width: 100 }, { type: 'string', name: 'description', label: true, header: i18next.t('field.description'), record: { editable: true }, filter: 'search', width: 400 }, { type: 'select', name: 'iteration', header: i18next.t('field.iteration-scope'), record: { editable: true, options: ['', 'SELF', 'CHILDREN', 'SELF & CHILDREN'] }, width: 120 }, { type: 'crontab', name: 'schedule', label: true, header: i18next.t('field.schedule'), record: { editable: true }, width: 110 }, { type: 'timezone', name: 'timezone', header: i18next.t('field.timezone'), record: { editable: true }, width: 120 }, { type: 'number', name: 'ttl', header: i18next.t('field.ttl-seconds'), record: { editable: true }, width: 80 }, { type: 'resource-object', name: 'role', label: true, header: i18next.t('field.required role'), record: { editable: true, options: { title: i18next.t('title.lookup role'), queryName: 'roles' } }, width: 200 }, { type: 'checkbox', name: 'active', label: true, header: i18next.t('field.startup-scenario'), record: { align: 'center', editable: true }, sortable: true, width: 60 }, { type: 'string', name: 'publishState', header: i18next.t('field.publish state'), record: { editable: false, align: 'center', // released → 라벨, draft → 'release' 버튼(클릭 시 릴리즈). 별도 버튼 컬럼 없이 상태 컬럼에서 처리. renderer: (value, column, record) => { if (!record || !record.id) return html`` if (value === 'released') { return html` check_circle${i18next.t('label.released')} ` } return html` ` } }, width: 100 }, { type: 'number', name: 'version', header: i18next.t('field.version'), record: { editable: false, align: 'center', // 버전 번호 옆에 history 아이콘 — 클릭 시 버전 이력 열기(별도 컬럼 없이 version 컬럼 재사용). renderer: (value, column, record) => { if (!record || !record.id) return html`` return html` v${value ?? 0} { e.stopPropagation() this.openVersionHistory(record) }} >history ` } }, width: 82 }, { type: 'object', name: 'updater', header: i18next.t('field.updater'), record: { editable: false }, width: 85 }, { type: 'datetime', name: 'updatedAt', header: i18next.t('field.updated_at'), record: { editable: false }, sortable: true, width: 180 } ], rows: { selectable: { multiple: true } }, sorters: [ { name: 'name' } ] } } async pageUpdated(changes, lifecycle) { if (this.active) { this.grist.fetch() } } async fetchHandler({ page, limit, sortings = [], filters = [] }: FetchOption) { const response = await client.query({ query: gql` query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) { responses: scenarios(filters: $filters, pagination: $pagination, sortings: $sortings) { items { id name type description active state publishState version iteration schedule scheduleId timezone ttl role { id name description } updater { id name } updatedAt steps { name description sequence task skip log connection params } } total } } `, variables: { filters, pagination: { page, limit }, sortings }, fetchPolicy: 'no-cache' }) return { total: response.data.responses.total || 0, records: response.data.responses.items || [] } } async fetchTaskTypes() { const response = await client.query({ query: gql` query { taskTypes { items { name description help parameterSpec { type name label placeholder property styles useDomainAttribute } } } } ` }) if (!response.errors) { this.taskTypes = response.data.taskTypes.items.reduce((taskTypes, taskType) => { taskTypes[taskType.name] = taskType return taskTypes }, {}) } else { console.error('fetch taskTypes error') } } async _deleteScenario() { if (confirm(i18next.t('text.sure_to_x', { x: i18next.t('text.delete') }))) { const ids = this.grist.selected.map(record => record.id) if (ids && ids.length > 0) { const response = await client.mutate({ mutation: gql` mutation ($ids: [String!]!) { deleteScenarios(ids: $ids) } `, variables: { ids } }) if (!response.errors) { this.grist.fetch() notify({ message: i18next.t('text.info_x_successfully', { x: i18next.t('text.delete') }) }) } } } } async _copyScenario() { var selected = this.grist.selected if (selected.length == 0) return if (!confirm(i18next.t('text.sure_to_x', { x: i18next.t('text.copy') }))) return var response = await client.mutate({ mutation: gql` mutation ($ids: [String!]!) { copyScenarios(ids: $ids) { id } } `, variables: { ids: selected.map(r => r.id) } }) if (!response.errors) this.grist.fetch() } async _updateScenario() { var patches = this.grist.dirtyRecords if (patches && patches.length) { patches = patches.map(patch => { let patchField: any = patch.id ? { id: patch.id } : {} const dirtyFields = patch.__dirtyfields__ for (let key in dirtyFields) { if (['message', 'step', 'steps', 'progress', 'rounds'].indexOf(key) == -1) { patchField[key] = dirtyFields[key].after } } patchField.cuFlag = patch.__dirty__ return patchField }) const response = await client.mutate({ mutation: gql` mutation ($patches: [ScenarioPatch!]!) { updateMultipleScenario(patches: $patches) { name } } `, variables: { patches } }) if (!response.errors) this.grist.fetch() } } async releaseScenario(record) { // 릴리즈 코멘트(선택) — OxPrompt.prompt(모달, native prompt 대체). 비우면 서버가 diff 요약으로 자동 채움. const input = await OxPrompt.prompt({ type: 'question', title: String(i18next.t('button.release')), text: String(i18next.t('text.release comment optional')), multiline: true, confirmButton: { text: String(i18next.t('button.release')) }, cancelButton: { text: String(i18next.t('button.cancel')) } }) if (input === null) return const comment = input.trim() || null const { errors } = await client.mutate({ mutation: gql` mutation ($id: String!, $comment: String) { releaseScenario(id: $id, comment: $comment) { id version publishState } } `, variables: { id: record.id, comment: comment || null } }) if (!errors) { notify({ message: i18next.t('text.info_x_successfully', { x: i18next.t('button.release') }) }) this.grist.fetch() } else { notify({ level: 'error', message: errors.map(error => error.message).join('\n') }) } } openVersionHistory(record) { openPopup( html` this.grist.fetch()} @reverted=${() => this.grist.fetch()} > `, { backdrop: true, size: 'medium', title: i18next.t('title.version history') } ) } async startScenario(record) { var { data, errors } = await client.mutate({ mutation: gql` mutation ($scenarioName: String!, $instanceName: String, $mode: String) { startScenario(scenarioName: $scenarioName, instanceName: $instanceName, mode: $mode) { state } } `, variables: { scenarioName: record.name, instanceName: record.name, mode: 'released' } }) if (data && data.startScenario) { record.state = data.startScenario.state notify({ level: 'info', message: `${IS_SCENARIO_RUNNING(state) ? 'success' : 'fail'} to start scenario : ${record.name}` }) } this.grist.fetch() } async stopScenario(record) { var response = await client.mutate({ mutation: gql` mutation ($instanceName: String!) { stopScenario(instanceName: $instanceName) { state } } `, variables: { instanceName: record.name } }) if (!response.errors) { notify({ level: 'info', message: `success to stop scenario : ${record.name}` }) } else { notify({ level: 'error', message: `${response.errors.map(error => error.message).join('\n')}` }) } this.grist.fetch() } async startScenarioSchedule(record) { var response = await client.mutate({ mutation: gql` mutation ($scenarioId: String!) { startScenarioSchedule(scenarioId: $scenarioId) { scheduleId } } `, variables: { scenarioId: record.id } }) const scheduleId = response.data.startScenarioSchedule.scheduleId record.scheduleId = scheduleId notify({ level: 'info', message: `${record.scheduleId ? 'success' : 'fail'} to start scenario schedule : ${record.name}` }) this.grist.fetch() } async stopScenarioSchedule(record) { var response = await client.mutate({ mutation: gql` mutation ($scenarioId: String!) { stopScenarioSchedule(scenarioId: $scenarioId) { scheduleId } } `, variables: { scenarioId: record.id } }) if (!response.errors) { notify({ level: 'info', message: `success to stop scenario schedule : ${record.name}` }) } else { notify({ level: 'error', message: `${response.errors.map(error => error.message).join('\n')}` }) } this.grist.fetch() } async exportHandler() { const exportTargets = this.grist.selected.length ? this.grist.selected : this.grist.dirtyData.records const targetFieldSet = new Set(['id', 'name', 'type', 'description', 'schedule', 'timezone', 'steps']) return exportTargets.map(scenario => { let tempObj = {} for (const field of targetFieldSet) { tempObj[field] = scenario[field] } return tempObj }) } async importHandler(records) { openPopup( html` `, { backdrop: true, size: 'large', title: i18next.t('title.import scenario') } ) } }