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`
this.grist.fetch()}"
>
`,
{ 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`
{ history.back()
this.grist.fetch()
}}"
>
`,
{ backdrop: true,
size: 'large',
title: i18next.t('title.import scenario')
}
)
}
}